xref: /aosp_15_r20/external/cldr/tools/cldr-code/src/main/java/org/unicode/cldr/util/VettingViewer.java (revision 912701f9769bb47905792267661f0baf2b85bed5)
1 package org.unicode.cldr.util;
2 
3 import com.ibm.icu.impl.Relation;
4 import com.ibm.icu.impl.Row;
5 import com.ibm.icu.impl.Row.R2;
6 import com.ibm.icu.text.NumberFormat;
7 import com.ibm.icu.text.UnicodeSet;
8 import com.ibm.icu.util.ICUUncheckedIOException;
9 import com.ibm.icu.util.Output;
10 import com.ibm.icu.util.ULocale;
11 import java.io.IOException;
12 import java.util.ArrayList;
13 import java.util.Arrays;
14 import java.util.Collections;
15 import java.util.EnumSet;
16 import java.util.HashMap;
17 import java.util.HashSet;
18 import java.util.LinkedHashSet;
19 import java.util.List;
20 import java.util.Map;
21 import java.util.Map.Entry;
22 import java.util.Objects;
23 import java.util.Set;
24 import java.util.TreeMap;
25 import java.util.TreeSet;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.ForkJoinPool;
28 import java.util.concurrent.RecursiveAction;
29 import org.unicode.cldr.test.CheckCLDR;
30 import org.unicode.cldr.test.CheckCLDR.CheckStatus;
31 import org.unicode.cldr.test.CheckCLDR.CheckStatus.Subtype;
32 import org.unicode.cldr.test.CheckCoverage;
33 import org.unicode.cldr.test.CheckNew;
34 import org.unicode.cldr.test.CoverageLevel2;
35 import org.unicode.cldr.test.OutdatedPaths;
36 import org.unicode.cldr.test.SubmissionLocales;
37 import org.unicode.cldr.util.CLDRFile.Status;
38 import org.unicode.cldr.util.PathHeader.PageId;
39 import org.unicode.cldr.util.PathHeader.SectionId;
40 import org.unicode.cldr.util.StandardCodes.LocaleCoverageType;
41 
42 /**
43  * Provides data for the Dashboard, showing the important issues for vetters to review for a given
44  * locale.
45  *
46  * <p>Also provides the Priority Items Summary, which is like a Dashboard that combines multiple
47  * locales.
48  *
49  * @author markdavis
50  */
51 public class VettingViewer<T> {
52 
53     private static final boolean DEBUG = false;
54 
55     private static final boolean SHOW_SUBTYPES = false;
56 
57     private static final String CONNECT_PREFIX = "₍_";
58     private static final String CONNECT_SUFFIX = "₎";
59 
60     private static final String TH_AND_STYLES = "<th class='tv-th' style='text-align:left'>";
61 
62     private static final String SPLIT_CHAR = "\uFFFE";
63 
64     private static final boolean DEBUG_THREADS = false;
65 
66     private static final Set<CheckCLDR.CheckStatus.Subtype> OK_IF_VOTED =
67             EnumSet.of(Subtype.sameAsEnglish);
68 
getNeutralOrgForSummary()69     public static Organization getNeutralOrgForSummary() {
70         return Organization.surveytool;
71     }
72 
orgIsNeutralForSummary(Organization org)73     private static boolean orgIsNeutralForSummary(Organization org) {
74         return org.equals(getNeutralOrgForSummary());
75     }
76 
77     private LocaleBaselineCount localeBaselineCount = null;
78 
setLocaleBaselineCount(LocaleBaselineCount localeBaselineCount)79     public void setLocaleBaselineCount(LocaleBaselineCount localeBaselineCount) {
80         this.localeBaselineCount = localeBaselineCount;
81     }
82 
getOutdatedPaths()83     public static OutdatedPaths getOutdatedPaths() {
84         return outdatedPaths;
85     }
86 
87     private static PathHeader.Factory pathTransform;
88     private static final OutdatedPaths outdatedPaths = new OutdatedPaths();
89 
90     /**
91      * @author markdavis
92      * @param <T>
93      */
94     public interface UsersChoice<T> {
95         /**
96          * Return the value that the user's organization (as a whole) voted for, or null if none of
97          * the users in the organization voted for the path. <br>
98          * NOTE: Would be easier if this were a method on CLDRFile. NOTE: if organization = null,
99          * then it must return the absolute winning value.
100          */
getWinningValueForUsersOrganization(CLDRFile cldrFile, String path, T organization)101         String getWinningValueForUsersOrganization(CLDRFile cldrFile, String path, T organization);
102 
103         /**
104          * Return the vote status NOTE: if organization = null, then it must disregard the
105          * organization and never return losing. See VoteStatus.
106          */
getStatusForUsersOrganization( CLDRFile cldrFile, String path, T organization)107         VoteResolver.VoteStatus getStatusForUsersOrganization(
108                 CLDRFile cldrFile, String path, T organization);
109 
110         /**
111          * Has the given user voted for the given path and locale?
112          *
113          * @param userId
114          * @param loc
115          * @param path
116          * @return true if that user has voted, else false
117          */
userDidVote(int userId, CLDRLocale loc, String path)118         boolean userDidVote(int userId, CLDRLocale loc, String path);
119 
getVoteResolver(CLDRFile baselineFile, CLDRLocale loc, String path)120         VoteResolver<String> getVoteResolver(CLDRFile baselineFile, CLDRLocale loc, String path);
121 
getUserVoteType(int userId, CLDRLocale loc, String path)122         VoteType getUserVoteType(int userId, CLDRLocale loc, String path);
123     }
124 
125     public interface ErrorChecker {
126         enum Status {
127             ok,
128             error,
129             warning
130         }
131 
132         /**
133          * Initialize an error checker with a cldrFile. MUST be called before any getErrorStatus.
134          */
initErrorStatus(CLDRFile cldrFile)135         Status initErrorStatus(CLDRFile cldrFile);
136 
137         /** Return the detailed CheckStatus information. */
getErrorCheckStatus(String path, String value)138         List<CheckStatus> getErrorCheckStatus(String path, String value);
139 
140         /**
141          * Return the status, and append the error message to the status message. If there are any
142          * errors, then the warnings are not included.
143          */
getErrorStatus(String path, String value, StringBuilder statusMessage)144         Status getErrorStatus(String path, String value, StringBuilder statusMessage);
145 
146         /**
147          * Return the status, and append the error message to the status message, and get the
148          * subtypes. If there are any errors, then the warnings are not included.
149          */
getErrorStatus( String path, String value, StringBuilder statusMessage, EnumSet<Subtype> outputSubtypes)150         Status getErrorStatus(
151                 String path,
152                 String value,
153                 StringBuilder statusMessage,
154                 EnumSet<Subtype> outputSubtypes);
155     }
156 
157     private static class DefaultErrorStatus implements ErrorChecker {
158 
159         private CheckCLDR checkCldr;
160         private CheckCLDR.Options options = null;
161         private ArrayList<CheckStatus> result = new ArrayList<>();
162         private CLDRFile cldrFile;
163         private final Factory factory;
164 
DefaultErrorStatus(Factory cldrFactory)165         private DefaultErrorStatus(Factory cldrFactory) {
166             this.factory = cldrFactory;
167         }
168 
169         @Override
initErrorStatus(CLDRFile cldrFile)170         public Status initErrorStatus(CLDRFile cldrFile) {
171             this.cldrFile = cldrFile;
172             options = new CheckCLDR.Options(CLDRLocale.getInstance(cldrFile.getLocaleID()));
173             result = new ArrayList<>();
174             // test initialization is handled by TestCache
175             return Status.ok;
176         }
177 
178         @Override
getErrorCheckStatus(String path, String value)179         public List<CheckStatus> getErrorCheckStatus(String path, String value) {
180             ArrayList<CheckStatus> result2 = new ArrayList<>();
181             factory.getTestCache().getBundle(options).check(path, result2, value);
182             return result2;
183         }
184 
185         @Override
getErrorStatus(String path, String value, StringBuilder statusMessage)186         public Status getErrorStatus(String path, String value, StringBuilder statusMessage) {
187             return getErrorStatus(path, value, statusMessage, null);
188         }
189 
190         @Override
getErrorStatus( String path, String value, StringBuilder statusMessage, EnumSet<Subtype> outputSubtypes)191         public Status getErrorStatus(
192                 String path,
193                 String value,
194                 StringBuilder statusMessage,
195                 EnumSet<Subtype> outputSubtypes) {
196             Status result0 = Status.ok;
197             StringBuilder errorMessage = new StringBuilder();
198             factory.getTestCache().getBundle(options).check(path, result, value);
199             for (CheckStatus checkStatus : result) {
200                 final CheckCLDR cause = checkStatus.getCause();
201                 /*
202                  * CheckCoverage will be shown under Missing, not under Warnings; and
203                  * CheckNew will be shown under New, not under Warnings; so skip them here.
204                  */
205                 if (cause instanceof CheckCoverage || cause instanceof CheckNew) {
206                     continue;
207                 }
208                 CheckStatus.Type statusType = checkStatus.getType();
209                 if (statusType.equals(CheckStatus.errorType)) {
210                     // throw away any accumulated warning messages
211                     if (result0 == Status.warning) {
212                         errorMessage.setLength(0);
213                         if (outputSubtypes != null) {
214                             outputSubtypes.clear();
215                         }
216                     }
217                     result0 = Status.error;
218                     if (outputSubtypes != null) {
219                         outputSubtypes.add(checkStatus.getSubtype());
220                     }
221                     appendToMessage(
222                             checkStatus.getMessage(), checkStatus.getSubtype(), errorMessage);
223                 } else if (result0 != Status.error && statusType.equals(CheckStatus.warningType)) {
224                     result0 = Status.warning;
225                     // accumulate all the warning messages
226                     if (outputSubtypes != null) {
227                         outputSubtypes.add(checkStatus.getSubtype());
228                     }
229                     appendToMessage(
230                             checkStatus.getMessage(), checkStatus.getSubtype(), errorMessage);
231                 }
232             }
233             if (result0 != Status.ok) {
234                 appendToMessage(errorMessage, statusMessage);
235             }
236             return result0;
237         }
238     }
239 
240     private final Factory cldrFactory;
241     private final CLDRFile englishFile;
242     private final UsersChoice<T> userVoteStatus;
243     private final SupplementalDataInfo supplementalDataInfo;
244     private final Set<String> defaultContentLocales;
245 
246     /**
247      * Create the Vetting Viewer.
248      *
249      * @param supplementalDataInfo
250      * @param cldrFactory
251      * @param userVoteStatus
252      */
VettingViewer( SupplementalDataInfo supplementalDataInfo, Factory cldrFactory, UsersChoice<T> userVoteStatus)253     public VettingViewer(
254             SupplementalDataInfo supplementalDataInfo,
255             Factory cldrFactory,
256             UsersChoice<T> userVoteStatus) {
257 
258         super();
259         this.cldrFactory = cldrFactory;
260         englishFile = cldrFactory.make("en", true);
261         if (pathTransform == null) {
262             pathTransform = PathHeader.getFactory(englishFile);
263         }
264         this.userVoteStatus = userVoteStatus;
265         this.supplementalDataInfo = supplementalDataInfo;
266         this.defaultContentLocales = supplementalDataInfo.getDefaultContentLocales();
267 
268         reasonsToPaths = Relation.of(new HashMap<>(), HashSet.class);
269     }
270 
271     public class WritingInfo implements Comparable<WritingInfo> {
272         public final PathHeader codeOutput;
273         public final Set<NotificationCategory> problems;
274         public final String htmlMessage;
275         public final Subtype subtype;
276 
WritingInfo( PathHeader ph, EnumSet<NotificationCategory> problems, CharSequence htmlMessage, Subtype subtype)277         public WritingInfo(
278                 PathHeader ph,
279                 EnumSet<NotificationCategory> problems,
280                 CharSequence htmlMessage,
281                 Subtype subtype) {
282             super();
283             this.codeOutput = ph;
284             this.problems = Collections.unmodifiableSet(problems.clone());
285             this.htmlMessage = htmlMessage.toString();
286             this.subtype = subtype;
287         }
288 
289         @Override
compareTo(WritingInfo other)290         public int compareTo(WritingInfo other) {
291             return codeOutput.compareTo(other.codeOutput);
292         }
293     }
294 
295     public class DashboardData {
296         public Relation<R2<SectionId, PageId>, WritingInfo> sorted =
297                 Relation.of(new TreeMap<R2<SectionId, PageId>, Set<WritingInfo>>(), TreeSet.class);
298 
299         public VoterProgress voterProgress = new VoterProgress();
300     }
301 
302     /**
303      * Generate the Dashboard
304      *
305      * @param args the DashboardArgs
306      * @return the DashboardData
307      */
generateDashboard(VettingParameters args)308     public DashboardData generateDashboard(VettingParameters args) {
309 
310         DashboardData dd = new DashboardData();
311 
312         FileInfo fileInfo =
313                 new FileInfo(
314                         args.locale.getBaseName(),
315                         args.coverageLevel,
316                         args.choices,
317                         (T) args.organization);
318         if (args.specificSinglePath != null) {
319             fileInfo.setSinglePath(args.specificSinglePath);
320         }
321         fileInfo.setFiles(args.sourceFile, args.baselineFile);
322         fileInfo.setSorted(dd.sorted);
323         fileInfo.setVoterProgressAndId(dd.voterProgress, args.userId);
324         fileInfo.getFileInfo();
325 
326         return dd;
327     }
328 
generateLocaleCompletion(VettingParameters args)329     public LocaleCompletionData generateLocaleCompletion(VettingParameters args) {
330         if (!args.sourceFile.isResolved()) {
331             throw new IllegalArgumentException("File must be resolved for locale completion");
332         }
333         FileInfo fileInfo =
334                 new FileInfo(
335                         args.locale.getBaseName(),
336                         args.coverageLevel,
337                         args.choices,
338                         (T) args.organization);
339         fileInfo.setFiles(args.sourceFile, args.baselineFile);
340         fileInfo.getFileInfo();
341         return new LocaleCompletionData(fileInfo.vc.problemCounter);
342     }
343 
344     private class VettingCounters {
345         private final Counter<NotificationCategory> problemCounter = new Counter<>();
346         private final Counter<Subtype> errorSubtypeCounter = new Counter<>();
347         private final Counter<Subtype> warningSubtypeCounter = new Counter<>();
348 
349         /**
350          * Combine some statistics into this VettingCounters from another VettingCounters
351          *
352          * <p>This is used by Priority Items Summary to combine stats from multiple locales.
353          *
354          * @param other the other VettingCounters object (for a single locale)
355          */
addAll(VettingCounters other)356         private void addAll(VettingCounters other) {
357             problemCounter.addAll(other.problemCounter);
358             errorSubtypeCounter.addAll(other.errorSubtypeCounter);
359             warningSubtypeCounter.addAll(other.warningSubtypeCounter);
360         }
361     }
362 
363     /**
364      * A FileInfo contains parameters, results, and methods for gathering information about a locale
365      */
366     private class FileInfo {
367         private final String localeId;
368         private final CLDRLocale cldrLocale;
369         private final Level usersLevel;
370         private final EnumSet<NotificationCategory> choices;
371         private final T organization;
372 
FileInfo( String localeId, Level level, EnumSet<NotificationCategory> choices, T organization)373         private FileInfo(
374                 String localeId,
375                 Level level,
376                 EnumSet<NotificationCategory> choices,
377                 T organization) {
378             this.localeId = localeId;
379             this.cldrLocale = CLDRLocale.getInstance(localeId);
380             this.usersLevel = level;
381             this.choices = choices;
382             this.organization = organization;
383         }
384 
385         private CLDRFile sourceFile = null;
386         private CLDRFile baselineFile = null;
387         private CLDRFile baselineFileUnresolved = null;
388         private boolean latin = false;
389 
setFiles(CLDRFile sourceFile, CLDRFile baselineFile)390         private void setFiles(CLDRFile sourceFile, CLDRFile baselineFile) {
391             this.sourceFile = sourceFile;
392             this.baselineFile = baselineFile;
393             this.baselineFileUnresolved =
394                     (baselineFile == null) ? null : baselineFile.getUnresolved();
395             this.latin = VettingViewer.isLatinScriptLocale(sourceFile);
396         }
397 
398         /** If not null, this object gets filled in with additional information */
399         private Relation<R2<SectionId, PageId>, WritingInfo> sorted = null;
400 
setSorted( Relation<R2<SectionId, PageId>, VettingViewer<T>.WritingInfo> sorted)401         private void setSorted(
402                 Relation<R2<SectionId, PageId>, VettingViewer<T>.WritingInfo> sorted) {
403             this.sorted = sorted;
404         }
405 
406         /** If voterId > 0, calculate voterProgress for the indicated user. */
407         private int voterId = 0;
408 
409         private VoterProgress voterProgress = null;
410 
setVoterProgressAndId(VoterProgress voterProgress, int userId)411         private void setVoterProgressAndId(VoterProgress voterProgress, int userId) {
412             this.voterProgress = voterProgress;
413             this.voterId = userId;
414         }
415 
416         private final VettingCounters vc = new VettingCounters();
417         private final EnumSet<NotificationCategory> problems =
418                 EnumSet.noneOf(NotificationCategory.class);
419         private final StringBuilder htmlMessage = new StringBuilder();
420         private final StringBuilder statusMessage = new StringBuilder();
421         private final EnumSet<Subtype> subtypes = EnumSet.noneOf(Subtype.class);
422         private final DefaultErrorStatus errorChecker = new DefaultErrorStatus(cldrFactory);
423 
424         /** If not null, getFileInfo will skip all paths except this one */
425         private String specificSinglePath = null;
426 
setSinglePath(String path)427         private void setSinglePath(String path) {
428             this.specificSinglePath = path;
429         }
430 
431         /**
432          * Loop through paths for the Dashboard or the Priority Items Summary
433          *
434          * @return the FileInfo
435          */
getFileInfo()436         private void getFileInfo() {
437             if (progressCallback.isStopped()) {
438                 throw new RuntimeException("Requested to stop");
439             }
440             errorChecker.initErrorStatus(sourceFile);
441             if (specificSinglePath != null) {
442                 handleOnePath(specificSinglePath);
443                 return;
444             }
445             Set<String> seenSoFar = new HashSet<>();
446             for (String path : sourceFile.fullIterable()) {
447                 if (seenSoFar.contains(path)) {
448                     continue;
449                 }
450                 seenSoFar.add(path);
451                 progressCallback.nudge(); // Let the user know we're moving along
452                 handleOnePath(path);
453             }
454         }
455 
handleOnePath(String path)456         private void handleOnePath(String path) {
457             PathHeader ph = pathTransform.fromPath(path);
458             if (ph == null || ph.shouldHide()) {
459                 return;
460             }
461             String value = sourceFile.getWinningValue(path);
462             statusMessage.setLength(0);
463             subtypes.clear();
464             ErrorChecker.Status errorStatus =
465                     errorChecker.getErrorStatus(path, value, statusMessage, subtypes);
466 
467             // note that the value might be missing!
468             Level pathLevel = supplementalDataInfo.getCoverageLevel(path, localeId);
469 
470             // skip all but errors above the requested level
471             boolean pathLevelIsTooHigh = pathLevel.compareTo(usersLevel) > 0;
472             boolean onlyRecordErrors = pathLevelIsTooHigh;
473 
474             problems.clear();
475             htmlMessage.setLength(0);
476 
477             final String oldValue =
478                     (baselineFileUnresolved == null)
479                             ? null
480                             : baselineFileUnresolved.getWinningValue(path);
481             if (skipForLimitedSubmission(path, errorStatus, oldValue)) {
482                 return;
483             }
484             if (!onlyRecordErrors
485                     && choices.contains(NotificationCategory.changedOldValue)
486                     && changedFromBaseline(path, value, oldValue, sourceFile)) {
487                 problems.add(NotificationCategory.changedOldValue);
488                 vc.problemCounter.increment(NotificationCategory.changedOldValue);
489             }
490             if (!onlyRecordErrors
491                     && choices.contains(NotificationCategory.inheritedChanged)
492                     && inheritedChangedFromBaseline(path, value, sourceFile)) {
493                 problems.add(NotificationCategory.inheritedChanged);
494                 vc.problemCounter.increment(NotificationCategory.inheritedChanged);
495             }
496             VoteResolver.VoteStatus voteStatus =
497                     userVoteStatus.getStatusForUsersOrganization(sourceFile, path, organization);
498             boolean itemsOkIfVoted = (voteStatus == VoteResolver.VoteStatus.ok);
499             MissingStatus missingStatus =
500                     onlyRecordErrors
501                             ? null
502                             : recordMissingChangedEtc(path, itemsOkIfVoted, value, oldValue);
503             recordChoice(errorStatus, itemsOkIfVoted, onlyRecordErrors);
504             if (!onlyRecordErrors) {
505                 recordLosingDisputedEtc(path, voteStatus, missingStatus);
506             }
507             if (pathLevelIsTooHigh && problems.isEmpty()) {
508                 return;
509             }
510             updateVotedOrAbstained(path);
511 
512             if (!problems.isEmpty() && sorted != null) {
513                 reasonsToPaths.clear();
514                 R2<SectionId, PageId> group = Row.of(ph.getSectionId(), ph.getPageId());
515                 sorted.put(group, new WritingInfo(ph, problems, htmlMessage, firstSubtype()));
516             }
517         }
518 
changedFromBaseline( String path, String value, String oldValue, CLDRFile sourceFile)519         private boolean changedFromBaseline(
520                 String path, String value, String oldValue, CLDRFile sourceFile) {
521             if (oldValue != null && !oldValue.equals(value)) {
522                 if (CldrUtility.INHERITANCE_MARKER.equals(oldValue)) {
523                     String baileyValue = sourceFile.getBaileyValue(path, null, null);
524                     if (baileyValue != null && baileyValue.equals(value)) {
525                         return false;
526                     }
527                 }
528                 return true;
529             }
530             return false;
531         }
532 
inheritedChangedFromBaseline( String path, String value, CLDRFile sourceFile)533         private boolean inheritedChangedFromBaseline(
534                 String path, String value, CLDRFile sourceFile) {
535             Output<String> pathWhereFound = new Output<>();
536             Output<String> localeWhereFound = new Output<>();
537             String baileyValue = sourceFile.getBaileyValue(path, pathWhereFound, localeWhereFound);
538             if (baileyValue == null
539                     || GlossonymConstructor.PSEUDO_PATH.equals(pathWhereFound.toString())
540                     || XMLSource.ROOT_ID.equals(localeWhereFound.toString())
541                     || XMLSource.CODE_FALLBACK_ID.equals(localeWhereFound.toString())) {
542                 return false;
543             }
544             if (!baileyValue.equals(value) && !CldrUtility.INHERITANCE_MARKER.equals(value)) {
545                 return false;
546             }
547             String baselineInheritedValue;
548             if (localeWhereFound.toString().equals(localeId)) { // sideways inheritance
549                 baselineInheritedValue = baselineFile.getWinningValue(pathWhereFound.toString());
550             } else { // inheritance from other locale
551                 Factory baselineFactory =
552                         CLDRConfig.getInstance().getCommonAndSeedAndMainAndAnnotationsFactory();
553                 CLDRFile parentFile = baselineFactory.make(localeWhereFound.toString(), true);
554                 baselineInheritedValue = parentFile.getWinningValue(pathWhereFound.toString());
555             }
556             return !baileyValue.equals(baselineInheritedValue);
557         }
558 
firstSubtype()559         private Subtype firstSubtype() {
560             for (Subtype subtype : subtypes) {
561                 if (subtype != Subtype.none) {
562                     return subtype;
563                 }
564             }
565             return Subtype.none;
566         }
567 
updateVotedOrAbstained(String path)568         private void updateVotedOrAbstained(String path) {
569             if (voterProgress == null || voterId == 0) {
570                 return;
571             }
572             voterProgress.incrementVotablePathCount();
573             if (userVoteStatus.userDidVote(voterId, cldrLocale, path)) {
574                 VoteType voteType = userVoteStatus.getUserVoteType(voterId, cldrLocale, path);
575                 voterProgress.incrementVotedPathCount(voteType);
576             } else if (choices.contains(NotificationCategory.abstained)) {
577                 problems.add(NotificationCategory.abstained);
578                 vc.problemCounter.increment(NotificationCategory.abstained);
579             }
580         }
581 
skipForLimitedSubmission( String path, ErrorChecker.Status errorStatus, String oldValue)582         private boolean skipForLimitedSubmission(
583                 String path, ErrorChecker.Status errorStatus, String oldValue) {
584             if (CheckCLDR.LIMITED_SUBMISSION) {
585                 boolean isError = (errorStatus == ErrorChecker.Status.error);
586                 boolean isMissing = (oldValue == null);
587                 if (!SubmissionLocales.allowEvenIfLimited(localeId, path, isError, isMissing)) {
588                     return true;
589                 }
590             }
591             return false;
592         }
593 
recordMissingChangedEtc( String path, boolean itemsOkIfVoted, String value, String oldValue)594         private MissingStatus recordMissingChangedEtc(
595                 String path, boolean itemsOkIfVoted, String value, String oldValue) {
596             VoteResolver<String> resolver =
597                     userVoteStatus.getVoteResolver(baselineFile, cldrLocale, path);
598             MissingStatus missingStatus;
599             if (resolver.getWinningStatus() == VoteResolver.Status.missing) {
600                 missingStatus = getMissingStatus(sourceFile, path, latin);
601             } else {
602                 missingStatus = MissingStatus.PRESENT;
603             }
604             if (choices.contains(NotificationCategory.missingCoverage)
605                     && missingStatus == MissingStatus.ABSENT) {
606                 problems.add(NotificationCategory.missingCoverage);
607                 vc.problemCounter.increment(NotificationCategory.missingCoverage);
608             }
609             if (!CheckCLDR.LIMITED_SUBMISSION
610                     && !itemsOkIfVoted
611                     && outdatedPaths.isOutdated(localeId, path)) {
612                 recordEnglishChanged(path, value, oldValue);
613             }
614             return missingStatus;
615         }
616 
recordEnglishChanged(String path, String value, String oldValue)617         private void recordEnglishChanged(String path, String value, String oldValue) {
618             if (Objects.equals(value, oldValue)
619                     && choices.contains(NotificationCategory.englishChanged)) {
620                 String oldEnglishValue = outdatedPaths.getPreviousEnglish(path);
621                 if (!OutdatedPaths.NO_VALUE.equals(oldEnglishValue)) {
622                     // check to see if we voted
623                     problems.add(NotificationCategory.englishChanged);
624                     vc.problemCounter.increment(NotificationCategory.englishChanged);
625                 }
626             }
627         }
628 
recordChoice( ErrorChecker.Status errorStatus, boolean itemsOkIfVoted, boolean onlyRecordErrors)629         private void recordChoice(
630                 ErrorChecker.Status errorStatus, boolean itemsOkIfVoted, boolean onlyRecordErrors) {
631             NotificationCategory choice =
632                     errorStatus == ErrorChecker.Status.error
633                             ? NotificationCategory.error
634                             : errorStatus == ErrorChecker.Status.warning
635                                     ? NotificationCategory.warning
636                                     : null;
637 
638             if (choice == NotificationCategory.error
639                     && choices.contains(NotificationCategory.error)
640                     && (!itemsOkIfVoted || !OK_IF_VOTED.containsAll(subtypes))) {
641                 problems.add(choice);
642                 appendToMessage(statusMessage, htmlMessage);
643                 vc.problemCounter.increment(choice);
644                 for (Subtype subtype : subtypes) {
645                     vc.errorSubtypeCounter.increment(subtype);
646                 }
647             } else if (!onlyRecordErrors
648                     && choice == NotificationCategory.warning
649                     && choices.contains(NotificationCategory.warning)
650                     && (!itemsOkIfVoted || !OK_IF_VOTED.containsAll(subtypes))) {
651                 problems.add(choice);
652                 appendToMessage(statusMessage, htmlMessage);
653                 vc.problemCounter.increment(choice);
654                 for (Subtype subtype : subtypes) {
655                     vc.warningSubtypeCounter.increment(subtype);
656                 }
657             }
658         }
659 
recordLosingDisputedEtc( String path, VoteResolver.VoteStatus voteStatus, MissingStatus missingStatus)660         private void recordLosingDisputedEtc(
661                 String path, VoteResolver.VoteStatus voteStatus, MissingStatus missingStatus) {
662             switch (voteStatus) {
663                 case losing:
664                     if (choices.contains(NotificationCategory.weLost)) {
665                         problems.add(NotificationCategory.weLost);
666                         vc.problemCounter.increment(NotificationCategory.weLost);
667                     }
668                     String usersValue =
669                             userVoteStatus.getWinningValueForUsersOrganization(
670                                     sourceFile, path, organization);
671                     if (usersValue != null) {
672                         usersValue =
673                                 "Losing value: <"
674                                         + TransliteratorUtilities.toHTML.transform(usersValue)
675                                         + ">";
676                         appendToMessage(usersValue, htmlMessage);
677                     }
678                     break;
679                 case disputed:
680                     if (choices.contains(NotificationCategory.hasDispute)) {
681                         problems.add(NotificationCategory.hasDispute);
682                         vc.problemCounter.increment(NotificationCategory.hasDispute);
683                     }
684                     break;
685                 case provisionalOrWorse:
686                     if (missingStatus == MissingStatus.PRESENT
687                             && choices.contains(NotificationCategory.notApproved)) {
688                         problems.add(NotificationCategory.notApproved);
689                         vc.problemCounter.increment(NotificationCategory.notApproved);
690                     }
691                     break;
692                 default:
693             }
694         }
695     }
696 
697     public final class LocalesWithExplicitLevel implements Predicate<String> {
698         private final Organization org;
699         private final Level desiredLevel;
700 
LocalesWithExplicitLevel(Organization org, Level level)701         public LocalesWithExplicitLevel(Organization org, Level level) {
702             this.org = org;
703             this.desiredLevel = level;
704         }
705 
706         @Override
is(String localeId)707         public boolean is(String localeId) {
708             StandardCodes sc = StandardCodes.make();
709             if (orgIsNeutralForSummary(org)) {
710                 if (!summarizeAllLocales && !SubmissionLocales.CLDR_LOCALES.contains(localeId)) {
711                     return false;
712                 }
713                 return desiredLevel == sc.getTargetCoverageLevel(localeId);
714             } else {
715                 Output<LocaleCoverageType> output = new Output<>();
716                 Level level = sc.getLocaleCoverageLevel(org, localeId, output);
717                 return desiredLevel == level
718                         && output.value == StandardCodes.LocaleCoverageType.explicit;
719             }
720         }
721     }
722 
723     /**
724      * Get the number of locales to be summarized for the given organization
725      *
726      * @param org the organization
727      * @return the number of locales
728      */
getLocaleCount(Organization org)729     public int getLocaleCount(Organization org) {
730         int localeCount = 0;
731         for (Level lv : Level.values()) {
732             Map<String, String> sortedNames = getSortedNames(org, lv);
733             localeCount += sortedNames.size();
734         }
735         return localeCount;
736     }
737 
738     /**
739      * Get the list of locales to be summarized for the given organization
740      *
741      * @param org the organization
742      * @return the list of locale id strings
743      */
getLocaleList(Organization org)744     public ArrayList<String> getLocaleList(Organization org) {
745         final ArrayList<String> list = new ArrayList<>();
746         for (Level lv : Level.values()) {
747             final Map<String, String> sortedNames = getSortedNames(org, lv);
748             for (Map.Entry<String, String> entry : sortedNames.entrySet()) {
749                 list.add(entry.getValue());
750             }
751         }
752         return list;
753     }
754 
generatePriorityItemsSummary( Appendable output, EnumSet<NotificationCategory> choices, T organization)755     public void generatePriorityItemsSummary(
756             Appendable output, EnumSet<NotificationCategory> choices, T organization)
757             throws ExecutionException {
758         try {
759             String header = makeSummaryHeader(choices);
760             for (Level level : Level.values()) {
761                 writeSummaryTable(output, header, level, choices, organization);
762             }
763         } catch (IOException e) {
764             throw new ICUUncheckedIOException(e); // dang'ed checked exceptions
765         }
766     }
767 
appendDisplay(StringBuilder target, NotificationCategory category)768     private void appendDisplay(StringBuilder target, NotificationCategory category)
769             throws IOException {
770         target.append("<span title='").append(category.description);
771         target.append("'>").append(category.buttonLabel).append("*</span>");
772     }
773 
774     /**
775      * This is a context object for Vetting Viewer parallel writes. It keeps track of the input
776      * locales, other parameters, as well as the output streams.
777      *
778      * <p>When done, appendTo() is called to append the output to the original requester.
779      *
780      * @author srl
781      */
782     private class WriteContext {
783 
784         private final List<String> localeNames = new ArrayList<>();
785         private final List<String> localeIds = new ArrayList<>();
786         private final StringBuffer[] outputs;
787         private final EnumSet<NotificationCategory> choices;
788         private final EnumSet<NotificationCategory> ourChoicesThatRequireOldFile;
789         private final T organization;
790         private final VettingViewer<T>.VettingCounters totals;
791         private final Map<String, VettingViewer<T>.FileInfo> localeNameToFileInfo;
792         private final String header;
793         private final int configChunkSize; // Number of locales to process at once, minimum 1
794 
WriteContext( Set<Entry<String, String>> entrySet, EnumSet<NotificationCategory> choices, T organization, VettingCounters totals, Map<String, FileInfo> localeNameToFileInfo, String header)795         private WriteContext(
796                 Set<Entry<String, String>> entrySet,
797                 EnumSet<NotificationCategory> choices,
798                 T organization,
799                 VettingCounters totals,
800                 Map<String, FileInfo> localeNameToFileInfo,
801                 String header) {
802             for (Entry<String, String> e : entrySet) {
803                 localeNames.add(e.getKey());
804                 localeIds.add(e.getValue());
805             }
806             int count = localeNames.size();
807             this.outputs = new StringBuffer[count];
808             for (int i = 0; i < count; i++) {
809                 outputs[i] = new StringBuffer();
810             }
811             if (DEBUG_THREADS) {
812                 System.out.println("Initted " + this.outputs.length + " outputs");
813             }
814 
815             // other data
816             this.choices = choices;
817 
818             EnumSet<NotificationCategory> thingsThatRequireOldFile =
819                     EnumSet.of(
820                             NotificationCategory.englishChanged,
821                             NotificationCategory.missingCoverage,
822                             NotificationCategory.changedOldValue,
823                             NotificationCategory.inheritedChanged);
824             ourChoicesThatRequireOldFile = choices.clone();
825             ourChoicesThatRequireOldFile.retainAll(thingsThatRequireOldFile);
826 
827             this.organization = organization;
828             this.totals = totals;
829             this.localeNameToFileInfo = localeNameToFileInfo;
830             this.header = header;
831 
832             if (DEBUG_THREADS) {
833                 System.out.println(
834                         "writeContext for "
835                                 + organization.toString()
836                                 + " booted with "
837                                 + count
838                                 + " locales");
839             }
840 
841             // setup env
842             CLDRConfig config = CLDRConfig.getInstance();
843 
844             // parallelism. 0 means "let Java decide"
845             int configParallel = Math.max(config.getProperty("CLDR_VETTINGVIEWER_PARALLEL", 0), 0);
846             if (configParallel < 1) {
847                 configParallel =
848                         java.lang.Runtime.getRuntime()
849                                 .availableProcessors(); // matches ForkJoinPool() behavior
850             }
851             this.configChunkSize =
852                     Math.max(config.getProperty("CLDR_VETTINGVIEWER_CHUNKSIZE", 1), 1);
853             if (DEBUG) {
854                 System.out.println(
855                         "vv: CLDR_VETTINGVIEWER_PARALLEL="
856                                 + configParallel
857                                 + ", CLDR_VETTINGVIEWER_CHUNKSIZE="
858                                 + configChunkSize);
859             }
860         }
861 
862         /**
863          * Append all of the results (one stream per locale) to the output parameter. Insert the
864          * "header" as needed.
865          *
866          * @param output
867          * @throws IOException
868          */
appendTo(Appendable output)869         private void appendTo(Appendable output) throws IOException {
870             // all done, append all
871             char lastChar = ' ';
872 
873             for (int n = 0; n < outputs.length; n++) {
874                 final String name = localeNames.get(n);
875                 if (DEBUG_THREADS) {
876                     System.out.println("Appending " + name + " - " + outputs[n].length());
877                 }
878                 char nextChar = name.charAt(0);
879                 if (lastChar != nextChar) {
880                     output.append(this.header);
881                     lastChar = nextChar;
882                 }
883                 output.append(outputs[n]);
884             }
885         }
886 
887         /**
888          * How many locales are represented in this context?
889          *
890          * @return
891          */
size()892         private int size() {
893             return localeNames.size();
894         }
895     }
896 
897     /**
898      * Worker action to implement parallel Vetting Viewer writes. This takes a WriteContext as a
899      * parameter, as well as a subset of the locales to operate on.
900      *
901      * @author srl
902      */
903     private class WriteAction extends RecursiveAction {
904         private final int length;
905         private final int start;
906         private final WriteContext context;
907 
WriteAction(WriteContext context)908         public WriteAction(WriteContext context) {
909             this(context, 0, context.size());
910         }
911 
WriteAction(WriteContext context, int start, int length)912         public WriteAction(WriteContext context, int start, int length) {
913             this.context = context;
914             this.start = start;
915             this.length = length;
916             if (DEBUG_THREADS) {
917                 System.out.println(
918                         "writeAction(…,"
919                                 + start
920                                 + ", "
921                                 + length
922                                 + ") of "
923                                 + context.size()
924                                 + " with outputCount:"
925                                 + context.outputs.length);
926             }
927         }
928 
929         private static final long serialVersionUID = 1L;
930 
931         @Override
compute()932         protected void compute() {
933             if (length == 0) {
934                 return;
935             } else if (length <= context.configChunkSize) {
936                 computeAll();
937             } else {
938                 int split = length / 2;
939                 // subdivide
940                 invokeAll(
941                         new WriteAction(context, start, split),
942                         new WriteAction(context, start + split, length - split));
943             }
944         }
945 
946         /** Compute this entire task. Can call this to run this step as a single thread. */
computeAll()947         private void computeAll() {
948             // do this many at once
949             for (int n = start; n < (start + length); n++) {
950                 computeOne(n);
951             }
952         }
953 
954         /**
955          * Calculate the Priority Items Summary output for one locale
956          *
957          * @param n
958          */
computeOne(int n)959         private void computeOne(int n) {
960             if (progressCallback.isStopped()) {
961                 throw new RuntimeException("Requested to stop");
962             }
963             if (DEBUG) {
964                 MemoryHelper.availableMemory("VettingViewer.WriteAction.computeOne", true);
965             }
966             final String name = context.localeNames.get(n);
967             final String localeID = context.localeIds.get(n);
968             if (DEBUG_THREADS) {
969                 System.out.println("writeAction.compute(" + n + ") - " + name + ": " + localeID);
970             }
971             EnumSet<NotificationCategory> choices = context.choices;
972             Appendable output = context.outputs[n];
973             if (output == null) {
974                 throw new NullPointerException("output " + n + " null");
975             }
976             // Initialize
977             CLDRFile sourceFile = cldrFactory.make(localeID, true);
978             CLDRFile baselineFile = null;
979             if (!context.ourChoicesThatRequireOldFile.isEmpty()) {
980                 try {
981                     Factory baselineFactory =
982                             CLDRConfig.getInstance().getCommonAndSeedAndMainAndAnnotationsFactory();
983                     baselineFile = baselineFactory.make(localeID, true);
984                 } catch (Exception e) {
985                 }
986             }
987             Level level = Level.MODERN;
988             if (context.organization != null) {
989                 StandardCodes sc = StandardCodes.make();
990                 if (orgIsNeutralForSummary((Organization) context.organization)) {
991                     level = sc.getTargetCoverageLevel(localeID);
992                 } else {
993                     level = sc.getLocaleCoverageLevel(context.organization.toString(), localeID);
994                 }
995             }
996             FileInfo fileInfo = new FileInfo(localeID, level, choices, context.organization);
997             fileInfo.setFiles(sourceFile, baselineFile);
998             fileInfo.getFileInfo();
999 
1000             if (context.localeNameToFileInfo != null) {
1001                 context.localeNameToFileInfo.put(name, fileInfo);
1002             }
1003 
1004             context.totals.addAll(fileInfo.vc);
1005             if (DEBUG_THREADS) {
1006                 System.out.println(
1007                         "writeAction.compute(" + n + ") - got fileinfo " + name + ": " + localeID);
1008             }
1009             try {
1010                 writeSummaryRow(output, choices, fileInfo.vc.problemCounter, name, localeID, level);
1011                 if (DEBUG_THREADS) {
1012                     System.out.println(
1013                             "writeAction.compute(" + n + ") - wrote " + name + ": " + localeID);
1014                 }
1015             } catch (IOException | ExecutionException e) {
1016                 System.err.println(
1017                         "writeAction.compute(" + n + ") - writeexc " + name + ": " + localeID);
1018                 this.completeExceptionally(new RuntimeException("While writing " + localeID, e));
1019             }
1020             if (DEBUG) {
1021                 System.out.println(
1022                         "writeAction.compute(" + n + ") - DONE " + name + ": " + localeID);
1023             }
1024         }
1025     }
1026 
1027     /**
1028      * Write the table for the Priority Items Summary
1029      *
1030      * @param output
1031      * @param header
1032      * @param desiredLevel
1033      * @param choices
1034      * @param organization
1035      * @throws IOException
1036      */
writeSummaryTable( Appendable output, String header, Level desiredLevel, EnumSet<NotificationCategory> choices, T organization)1037     private void writeSummaryTable(
1038             Appendable output,
1039             String header,
1040             Level desiredLevel,
1041             EnumSet<NotificationCategory> choices,
1042             T organization)
1043             throws IOException, ExecutionException {
1044         Map<String, String> sortedNames = getSortedNames((Organization) organization, desiredLevel);
1045         if (sortedNames.isEmpty()) {
1046             return;
1047         }
1048         output.append("<h2>Level: ").append(desiredLevel.toString()).append("</h2>");
1049         output.append("<table class='tvs-table'>\n");
1050 
1051         // Caution: localeNameToFileInfo, if not null, may lead to running out of memory
1052         Map<String, FileInfo> localeNameToFileInfo = SHOW_SUBTYPES ? new TreeMap<>() : null;
1053 
1054         VettingCounters totals = new VettingCounters();
1055 
1056         Set<Entry<String, String>> entrySet = sortedNames.entrySet();
1057 
1058         WriteContext context =
1059                 this
1060                 .new WriteContext(
1061                         entrySet, choices, organization, totals, localeNameToFileInfo, header);
1062 
1063         WriteAction writeAction = this.new WriteAction(context);
1064         if (USE_FORKJOIN) {
1065             ForkJoinPool.commonPool().invoke(writeAction);
1066         } else {
1067             if (DEBUG) {
1068                 System.out.println(
1069                         "WARNING: calling writeAction.computeAll(), as the ForkJoinPool is disabled.");
1070             }
1071             writeAction.computeAll();
1072         }
1073         context.appendTo(output); // write all of the results together
1074         output.append(header); // add one header at the bottom before the Total row
1075         writeSummaryRow(output, choices, totals.problemCounter, "Total", null, desiredLevel);
1076         output.append("</table>");
1077         if (SHOW_SUBTYPES) {
1078             showSubtypes(output, sortedNames, localeNameToFileInfo, totals, true);
1079             showSubtypes(output, sortedNames, localeNameToFileInfo, totals, false);
1080         }
1081     }
1082 
getSortedNames(Organization org, Level desiredLevel)1083     private Map<String, String> getSortedNames(Organization org, Level desiredLevel) {
1084         Map<String, String> sortedNames = new TreeMap<>(CLDRConfig.getInstance().getCollator());
1085         // TODO Fix HACK
1086         // We are going to ignore the predicate for now, just using the locales that have explicit
1087         // coverage.
1088         // in that locale, or allow all locales for admin@
1089         LocalesWithExplicitLevel includeLocale = new LocalesWithExplicitLevel(org, desiredLevel);
1090 
1091         for (String localeID : cldrFactory.getAvailable()) {
1092             if (defaultContentLocales.contains(localeID)
1093                     || localeID.equals("en")
1094                     || !includeLocale.is(localeID)) {
1095                 continue;
1096             }
1097             sortedNames.put(getName(localeID), localeID);
1098         }
1099         return sortedNames;
1100     }
1101 
1102     private final boolean USE_FORKJOIN = false;
1103 
showSubtypes( Appendable output, Map<String, String> sortedNames, Map<String, FileInfo> localeNameToFileInfo, VettingCounters totals, boolean errors)1104     private void showSubtypes(
1105             Appendable output,
1106             Map<String, String> sortedNames,
1107             Map<String, FileInfo> localeNameToFileInfo,
1108             VettingCounters totals,
1109             boolean errors)
1110             throws IOException {
1111 
1112         output.append("<h3>Details: ")
1113                 .append(errors ? "Error Types" : "Warning Types")
1114                 .append("</h3>");
1115         output.append("<table class='tvs-table'>");
1116         Counter<Subtype> subtypeCounterTotals =
1117                 errors ? totals.errorSubtypeCounter : totals.warningSubtypeCounter;
1118         Set<Subtype> sortedBySize = subtypeCounterTotals.getKeysetSortedByCount(false);
1119 
1120         // header
1121         writeDetailHeader(sortedBySize, output);
1122 
1123         // items
1124         for (Entry<String, FileInfo> entry : localeNameToFileInfo.entrySet()) {
1125             Counter<Subtype> counter =
1126                     errors
1127                             ? entry.getValue().vc.errorSubtypeCounter
1128                             : entry.getValue().vc.warningSubtypeCounter;
1129             if (counter.getTotal() == 0) {
1130                 continue;
1131             }
1132             String name = entry.getKey();
1133             String localeID = sortedNames.get(name);
1134             output.append("<tr>").append(TH_AND_STYLES);
1135             appendNameAndCode(name, localeID, output);
1136             output.append("</th>");
1137             for (Subtype subtype : sortedBySize) {
1138                 long count = counter.get(subtype);
1139                 output.append("<td class='tvs-count'>");
1140                 if (count != 0) {
1141                     output.append(nf.format(count));
1142                 }
1143                 output.append("</td>");
1144             }
1145         }
1146 
1147         // subtotals
1148         writeDetailHeader(sortedBySize, output);
1149         output.append("<tr>")
1150                 .append(TH_AND_STYLES)
1151                 .append("<i>Total</i>")
1152                 .append("</th>")
1153                 .append(TH_AND_STYLES)
1154                 .append("</th>");
1155         for (Subtype subtype : sortedBySize) {
1156             long count = subtypeCounterTotals.get(subtype);
1157             output.append("<td class='tvs-count'>");
1158             if (count != 0) {
1159                 output.append("<b>").append(nf.format(count)).append("</b>");
1160             }
1161             output.append("</td>");
1162         }
1163         output.append("</table>");
1164     }
1165 
writeDetailHeader(Set<Subtype> sortedBySize, Appendable output)1166     private void writeDetailHeader(Set<Subtype> sortedBySize, Appendable output)
1167             throws IOException {
1168         output.append("<tr>")
1169                 .append(TH_AND_STYLES)
1170                 .append("Name")
1171                 .append("</th>")
1172                 .append(TH_AND_STYLES)
1173                 .append("ID")
1174                 .append("</th>");
1175         for (Subtype subtype : sortedBySize) {
1176             output.append(TH_AND_STYLES).append(subtype.toString()).append("</th>");
1177         }
1178     }
1179 
makeSummaryHeader(EnumSet<NotificationCategory> choices)1180     private String makeSummaryHeader(EnumSet<NotificationCategory> choices) throws IOException {
1181         StringBuilder headerRow = new StringBuilder();
1182         headerRow
1183                 .append("<tr class='tvs-tr'>")
1184                 .append(TH_AND_STYLES)
1185                 .append("Level</th>")
1186                 .append(TH_AND_STYLES)
1187                 .append("Locale</th>")
1188                 .append(TH_AND_STYLES)
1189                 .append("Codes</th>")
1190                 .append(TH_AND_STYLES)
1191                 .append("Progress</th>");
1192         for (NotificationCategory choice : choices) {
1193             headerRow.append("<th class='tv-th'>");
1194             appendDisplay(headerRow, choice);
1195             headerRow.append("</th>");
1196         }
1197         headerRow.append(TH_AND_STYLES).append("Status</th>");
1198         headerRow.append("</tr>\n");
1199         return headerRow.toString();
1200     }
1201 
1202     /**
1203      * Write one row of the Priority Items Summary
1204      *
1205      * @param output
1206      * @param choices
1207      * @param problemCounter
1208      * @param name
1209      * @param localeID if null, this is a "Total" row to be shown at the bottom of the table
1210      * @param level
1211      * @throws IOException
1212      *     <p>CAUTION: this method not only uses "th" for "table header" in the usual sense, it also
1213      *     uses "th" for cells that contain data, including locale names like "Kashmiri
1214      *     (Devanagari)" and code values like "<code>ks_Deva₍_IN₎</code>". The same row may have
1215      *     both "th" and "td" cells.
1216      */
writeSummaryRow( Appendable output, EnumSet<NotificationCategory> choices, Counter<NotificationCategory> problemCounter, String name, String localeID, Level level)1217     private void writeSummaryRow(
1218             Appendable output,
1219             EnumSet<NotificationCategory> choices,
1220             Counter<NotificationCategory> problemCounter,
1221             String name,
1222             String localeID,
1223             Level level)
1224             throws IOException, ExecutionException {
1225         output.append("<tr>")
1226                 .append(TH_AND_STYLES)
1227                 .append(level.toString())
1228                 .append("</th>")
1229                 .append(TH_AND_STYLES);
1230         if (localeID == null) {
1231             output.append("<i>")
1232                     .append(name) // here always name = "Total"
1233                     .append("</i>")
1234                     .append("</th>")
1235                     .append(TH_AND_STYLES); // empty cell for Codes
1236         } else {
1237             appendNameAndCode(name, localeID, output);
1238         }
1239         output.append("</th>\n");
1240         final String progPerc =
1241                 (localeID == null) ? "" : getLocaleProgressPercent(localeID, problemCounter);
1242         output.append("<td class='tvs-count'>").append(progPerc).append("</td>\n");
1243         for (NotificationCategory choice : choices) {
1244             long count = problemCounter.get(choice);
1245             output.append("<td class='tvs-count'>");
1246             if (localeID == null) {
1247                 output.append("<b>");
1248             }
1249             output.append(nf.format(count));
1250             if (localeID == null) {
1251                 output.append("</b>");
1252             }
1253             output.append("</td>\n");
1254         }
1255         addLocaleStatusColumn(output, localeID);
1256         output.append("</tr>\n");
1257     }
1258 
addLocaleStatusColumn(Appendable output, String localeID)1259     private void addLocaleStatusColumn(Appendable output, String localeID) throws IOException {
1260         output.append("<td class='tvs-count'>");
1261         if (localeID != null) {
1262             output.append(getLocaleStatusColumn(CLDRLocale.getInstance(localeID)));
1263         }
1264         output.append("</td>\n");
1265     }
1266 
getLocaleStatusColumn(CLDRLocale locale)1267     private String getLocaleStatusColumn(CLDRLocale locale) {
1268         if (SpecialLocales.getType(locale) == SpecialLocales.Type.algorithmic) {
1269             return "AL"; // algorithmic
1270         } else if (Organization.special.getCoveredLocales().containsLocaleOrParent(locale)) {
1271             return "HC"; // high coverage
1272         } else if (Organization.cldr.getCoveredLocales().containsLocaleOrParent(locale)) {
1273             return "TC"; // Technical Committee
1274         } else {
1275             return "";
1276         }
1277     }
1278 
getLocaleProgressPercent( String localeId, Counter<NotificationCategory> problemCounter)1279     private String getLocaleProgressPercent(
1280             String localeId, Counter<NotificationCategory> problemCounter)
1281             throws ExecutionException {
1282         final LocaleCompletionData lcd = new LocaleCompletionData(problemCounter);
1283         final int problemCount = lcd.problemCount();
1284         final int total =
1285                 localeBaselineCount.getBaselineProblemCount(CLDRLocale.getInstance(localeId));
1286         final int done = (problemCount >= total) ? 0 : total - problemCount;
1287         // return CompletionPercent.calculate(done, total) + "%";
1288 
1289         // Adjust according to https://unicode-org.atlassian.net/browse/CLDR-15785
1290         // This is NOT a logical long-term solution
1291         int perc = CompletionPercent.calculate(done, total);
1292         if (perc == 100 && problemCount > 0) {
1293             perc = 99;
1294         }
1295         return perc + "%";
1296     }
1297 
appendNameAndCode(String name, String localeID, Appendable output)1298     private void appendNameAndCode(String name, String localeID, Appendable output)
1299             throws IOException {
1300         // See https://unicode-org.atlassian.net/browse/CLDR-15279
1301         String url = "v#/" + localeID + "//";
1302         String[] names = name.split(SPLIT_CHAR);
1303         output.append("<a href='" + url)
1304                 .append("'>")
1305                 .append(TransliteratorUtilities.toHTML.transform(names[0]))
1306                 .append("</a>")
1307                 .append("</th>")
1308                 .append(TH_AND_STYLES)
1309                 .append("<code>")
1310                 .append(names[1])
1311                 .append("</code>");
1312     }
1313 
getName(String localeID)1314     private String getName(String localeID) {
1315         Set<String> contents = supplementalDataInfo.getEquivalentsForLocale(localeID);
1316         // put in special character that can be split on later
1317         return englishFile.getName(localeID, true, CLDRFile.SHORT_ALTS)
1318                 + SPLIT_CHAR
1319                 + gatherCodes(contents);
1320     }
1321 
1322     /**
1323      * Collapse the names {en_Cyrl, en_Cyrl_US} => en_Cyrl(_US) {en_GB, en_Latn_GB} => en(_Latn)_GB
1324      * {en, en_US, en_Latn, en_Latn_US} => en(_Latn)(_US) {az_IR, az_Arab, az_Arab_IR} => az_IR,
1325      * az_Arab(_IR)
1326      */
gatherCodes(Set<String> contents)1327     private static String gatherCodes(Set<String> contents) {
1328         Set<Set<String>> source = new LinkedHashSet<>();
1329         for (String s : contents) {
1330             source.add(new LinkedHashSet<>(Arrays.asList(s.split("_"))));
1331         }
1332         Set<Set<String>> oldSource = new LinkedHashSet<>();
1333 
1334         do {
1335             // exchange source/target
1336             oldSource.clear();
1337             oldSource.addAll(source);
1338             source.clear();
1339             Set<String> last = null;
1340             for (Set<String> ss : oldSource) {
1341                 if (last == null) {
1342                     last = ss;
1343                 } else {
1344                     if (ss.containsAll(last)) {
1345                         last = combine(last, ss);
1346                     } else {
1347                         source.add(last);
1348                         last = ss;
1349                     }
1350                 }
1351             }
1352             source.add(last);
1353         } while (oldSource.size() != source.size());
1354 
1355         StringBuilder b = new StringBuilder();
1356         for (Set<String> stringSet : source) {
1357             if (b.length() != 0) {
1358                 b.append(", ");
1359             }
1360             String sep = "";
1361             for (String string : stringSet) {
1362                 if (string.startsWith(CONNECT_PREFIX)) {
1363                     b.append(string + CONNECT_SUFFIX);
1364                 } else {
1365                     b.append(sep + string);
1366                 }
1367                 sep = "_";
1368             }
1369         }
1370         return b.toString();
1371     }
1372 
combine(Set<String> last, Set<String> ss)1373     private static Set<String> combine(Set<String> last, Set<String> ss) {
1374         LinkedHashSet<String> result = new LinkedHashSet<>();
1375         for (String s : ss) {
1376             if (last.contains(s)) {
1377                 result.add(s);
1378             } else {
1379                 result.add(CONNECT_PREFIX + s);
1380             }
1381         }
1382         return result;
1383     }
1384 
1385     /** Used to determine what the status of a particular path's value is in a given locale. */
1386     public enum MissingStatus {
1387         /**
1388          * There is an explicit value for the path, including ↑↑↑, or there is an inherited value
1389          * (but not including the ABSENT conditions, e.g. not from root).
1390          */
1391         PRESENT,
1392 
1393         /**
1394          * The value is inherited from a different path. Only applies if the parent is not root.
1395          * That path might be in the same locale or from a parent (but not root or CODE_FALLBACK).
1396          */
1397         ALIASED,
1398 
1399         /** See ABSENT */
1400         MISSING_OK,
1401 
1402         /** See ABSENT */
1403         ROOT_OK,
1404 
1405         /**
1406          * The supplied CLDRFile is null, or the value is null, or the value is inherited from root
1407          * or CODE_FALLBACK. A special ValuePathStatus.isMissingOk method allows for some
1408          * exceptions, changing the result to MISSING_OK or ROOT_OK.
1409          */
1410         ABSENT
1411     }
1412 
1413     /**
1414      * Get the MissingStatus: for details see the javadoc for MissingStatus.
1415      *
1416      * @param sourceFile the CLDRFile
1417      * @param path the path
1418      * @param latin boolean from isLatinScriptLocale, passed to isMissingOk
1419      * @return the MissingStatus
1420      */
getMissingStatus(CLDRFile sourceFile, String path, boolean latin)1421     public static MissingStatus getMissingStatus(CLDRFile sourceFile, String path, boolean latin) {
1422         if (sourceFile == null) {
1423             return MissingStatus.ABSENT;
1424         }
1425         final String sourceLocaleID = sourceFile.getLocaleID();
1426         if ("root".equals(sourceLocaleID)) {
1427             return MissingStatus.MISSING_OK;
1428         }
1429         MissingStatus result;
1430 
1431         String value = sourceFile.getStringValue(path);
1432         Status status = new Status();
1433         String sourceLocale =
1434                 sourceFile.getSourceLocaleIdExtended(
1435                         path, status, false); // does not skip inheritance marker
1436 
1437         boolean isAliased = !path.equals(status.pathWhereFound);
1438         if (DEBUG) {
1439             if (path.equals("//ldml/characterLabels/characterLabelPattern[@type=\"subscript\"]")) {
1440                 int debug = 0;
1441             }
1442             if (!isAliased && !sourceLocale.equals(sourceLocaleID)) {
1443                 int debug = 0;
1444             }
1445         }
1446 
1447         if (value == null) {
1448             result =
1449                     ValuePathStatus.isMissingOk(sourceFile, path, latin, isAliased)
1450                             ? MissingStatus.MISSING_OK
1451                             : MissingStatus.ABSENT;
1452         } else {
1453             /*
1454              * skipInheritanceMarker must be false for getSourceLocaleIdExtended here, since INHERITANCE_MARKER
1455              * may be found if there are votes for inheritance, in which case we must not skip up to "root" and
1456              * treat the item as missing. Reference: https://unicode.org/cldr/trac/ticket/11765
1457              */
1458             String localeFound =
1459                     sourceFile.getSourceLocaleIdExtended(
1460                             path, status, false /* skipInheritanceMarker */);
1461             final boolean localeFoundIsRootOrCodeFallback =
1462                     localeFound.equals(XMLSource.ROOT_ID)
1463                             || localeFound.equals(XMLSource.CODE_FALLBACK_ID);
1464             final boolean isParentRoot =
1465                     CLDRLocale.getInstance(sourceFile.getLocaleID()).isParentRoot();
1466             /*
1467              * Only count it as missing IF the (localeFound is root or codeFallback)
1468              * AND the aliasing didn't change the path.
1469              * Note that localeFound will be where an item with ↑↑↑ was found even though
1470              * the resolved value is actually inherited from somewhere else.
1471              */
1472 
1473             if (localeFoundIsRootOrCodeFallback) {
1474                 result =
1475                         ValuePathStatus.isMissingOk(sourceFile, path, latin, isAliased)
1476                                 ? MissingStatus.ROOT_OK
1477                                 : isParentRoot ? MissingStatus.ABSENT : MissingStatus.ALIASED;
1478             } else if (!isAliased) {
1479                 result = MissingStatus.PRESENT;
1480             } else if (isParentRoot) { // We handle ALIASED specially, depending on whether the
1481                 // parent is root or not.
1482                 result =
1483                         ValuePathStatus.isMissingOk(sourceFile, path, latin, isAliased)
1484                                 ? MissingStatus.MISSING_OK
1485                                 : MissingStatus.ABSENT;
1486             } else {
1487                 result = MissingStatus.ALIASED;
1488             }
1489         }
1490         return result;
1491     }
1492 
1493     public static final UnicodeSet LATIN = ValuePathStatus.LATIN;
1494 
isLatinScriptLocale(CLDRFile sourceFile)1495     public static boolean isLatinScriptLocale(CLDRFile sourceFile) {
1496         return ValuePathStatus.isLatinScriptLocale(sourceFile);
1497     }
1498 
appendToMessage( CharSequence usersValue, Subtype subtype, StringBuilder testMessage)1499     private static void appendToMessage(
1500             CharSequence usersValue, Subtype subtype, StringBuilder testMessage) {
1501         if (subtype != null) {
1502             usersValue = "&lt;" + subtype + "&gt; " + usersValue;
1503         }
1504         appendToMessage(usersValue, testMessage);
1505     }
1506 
appendToMessage(CharSequence usersValue, StringBuilder testMessage)1507     private static void appendToMessage(CharSequence usersValue, StringBuilder testMessage) {
1508         if (usersValue.length() == 0) {
1509             return;
1510         }
1511         if (testMessage.length() != 0) {
1512             testMessage.append("<br>");
1513         }
1514         testMessage.append(usersValue);
1515     }
1516 
1517     static final NumberFormat nf = NumberFormat.getIntegerInstance(ULocale.ENGLISH);
1518     private final Relation<String, String> reasonsToPaths;
1519 
1520     static {
1521         nf.setGroupingUsed(true);
1522     }
1523 
1524     /**
1525      * Class that allows the relaying of progress information
1526      *
1527      * @author srl
1528      */
1529     public static class ProgressCallback {
1530         /**
1531          * Note any progress. This will be called before any output is printed. It will be called
1532          * approximately once per xpath.
1533          */
nudge()1534         public void nudge() {}
1535 
1536         /** Called when all operations are complete. */
done()1537         public void done() {}
1538 
1539         /**
1540          * Return true to cause an early stop.
1541          *
1542          * @return
1543          */
isStopped()1544         public boolean isStopped() {
1545             return false;
1546         }
1547     }
1548 
1549     /*
1550      * null instance by default
1551      */
1552     private ProgressCallback progressCallback = new ProgressCallback();
1553 
1554     /**
1555      * Select a new callback. Must be set before running.
1556      *
1557      * @return
1558      */
setProgressCallback(ProgressCallback newCallback)1559     public VettingViewer<T> setProgressCallback(ProgressCallback newCallback) {
1560         progressCallback = newCallback;
1561         return this;
1562     }
1563 
1564     /**
1565      * Find the status of all the paths in the input file. See the full getStatus for more
1566      * information.
1567      *
1568      * @param file the source. Must be a resolved file, made with minimalDraftStatus = unconfirmed
1569      * @param pathHeaderFactory PathHeaderFactory.
1570      * @param foundCounter output counter of the number of paths with values having contributed or
1571      *     approved status
1572      * @param unconfirmedCounter output counter of the number of paths with values, but neither
1573      *     contributed nor approved status
1574      * @param missingCounter output counter of the number of paths without values
1575      * @param missingPaths output if not null, the specific paths that are missing.
1576      * @param unconfirmedPaths TODO
1577      */
getStatus( CLDRFile file, PathHeader.Factory pathHeaderFactory, Counter<Level> foundCounter, Counter<Level> unconfirmedCounter, Counter<Level> missingCounter, Relation<MissingStatus, String> missingPaths, Set<String> unconfirmedPaths)1578     public static void getStatus(
1579             CLDRFile file,
1580             PathHeader.Factory pathHeaderFactory,
1581             Counter<Level> foundCounter,
1582             Counter<Level> unconfirmedCounter,
1583             Counter<Level> missingCounter,
1584             Relation<MissingStatus, String> missingPaths,
1585             Set<String> unconfirmedPaths) {
1586         getStatus(
1587                 file.fullIterable(),
1588                 file,
1589                 pathHeaderFactory,
1590                 foundCounter,
1591                 unconfirmedCounter,
1592                 missingCounter,
1593                 missingPaths,
1594                 unconfirmedPaths);
1595     }
1596 
1597     /**
1598      * Find the status of an input set of paths in the input file. It partitions the returned data
1599      * according to the Coverage levels. NOTE: MissingStatus.ALIASED is handled specially; it is
1600      * mapped to ABSENT if the parent is root, and otherwise mapped to PRESENT.
1601      *
1602      * @param allPaths manual list of paths
1603      * @param file the source. Must be a resolved file, made with minimalDraftStatus = unconfirmed
1604      * @param pathHeaderFactory PathHeaderFactory.
1605      * @param foundCounter output counter of the number of paths with values having contributed or
1606      *     approved status
1607      * @param unconfirmedCounter output counter of the number of paths with values, but neither
1608      *     contributed nor approved status
1609      * @param missingCounter output counter of the number of paths without values
1610      * @param missingPaths output if not null, the specific paths that are missing.
1611      * @param unconfirmedPaths TODO
1612      */
getStatus( Iterable<String> allPaths, CLDRFile file, PathHeader.Factory pathHeaderFactory, Counter<Level> foundCounter, Counter<Level> unconfirmedCounter, Counter<Level> missingCounter, Relation<MissingStatus, String> missingPaths, Set<String> unconfirmedPaths)1613     public static void getStatus(
1614             Iterable<String> allPaths,
1615             CLDRFile file,
1616             PathHeader.Factory pathHeaderFactory,
1617             Counter<Level> foundCounter,
1618             Counter<Level> unconfirmedCounter,
1619             Counter<Level> missingCounter,
1620             Relation<MissingStatus, String> missingPaths,
1621             Set<String> unconfirmedPaths) {
1622 
1623         if (!file.isResolved()) {
1624             throw new IllegalArgumentException("File must be resolved, no minimal draft status");
1625         }
1626         foundCounter.clear();
1627         unconfirmedCounter.clear();
1628         missingCounter.clear();
1629 
1630         boolean latin = VettingViewer.isLatinScriptLocale(file);
1631         CoverageLevel2 coverageLevel2 =
1632                 CoverageLevel2.getInstance(SupplementalDataInfo.getInstance(), file.getLocaleID());
1633 
1634         for (String path : allPaths) {
1635 
1636             PathHeader ph = pathHeaderFactory.fromPath(path);
1637             if (ph.getSectionId() == SectionId.Special) {
1638                 continue;
1639             }
1640 
1641             Level level = coverageLevel2.getLevel(path);
1642             if (level.compareTo(Level.MODERN) > 0) {
1643                 continue;
1644             }
1645             MissingStatus missingStatus = VettingViewer.getMissingStatus(file, path, latin);
1646 
1647             switch (missingStatus) {
1648                 case ABSENT:
1649                     missingCounter.add(level, 1);
1650                     if (missingPaths != null) {
1651                         missingPaths.put(missingStatus, path);
1652                     }
1653                     break;
1654                 case ALIASED:
1655                 case PRESENT:
1656                     String fullPath = file.getFullXPath(path);
1657                     if (fullPath.contains("unconfirmed") || fullPath.contains("provisional")) {
1658                         unconfirmedCounter.add(level, 1);
1659                         if (unconfirmedPaths != null) {
1660                             unconfirmedPaths.add(path);
1661                         }
1662                     } else {
1663                         foundCounter.add(level, 1);
1664                     }
1665                     break;
1666                 case MISSING_OK:
1667                 case ROOT_OK:
1668                     break;
1669                 default:
1670                     throw new IllegalArgumentException();
1671             }
1672         }
1673     }
1674 
1675     private static final EnumSet<NotificationCategory> localeCompletionCategories =
1676             EnumSet.of(
1677                     NotificationCategory.error,
1678                     NotificationCategory.hasDispute,
1679                     NotificationCategory.notApproved,
1680                     NotificationCategory.missingCoverage);
1681 
getDashboardNotificationCategories( Organization usersOrg)1682     public static EnumSet<NotificationCategory> getDashboardNotificationCategories(
1683             Organization usersOrg) {
1684         EnumSet<NotificationCategory> choiceSet = EnumSet.allOf(NotificationCategory.class);
1685         if (orgIsNeutralForSummary(usersOrg)) {
1686             choiceSet =
1687                     EnumSet.of(
1688                             NotificationCategory.error,
1689                             NotificationCategory.warning,
1690                             NotificationCategory.hasDispute,
1691                             NotificationCategory.notApproved,
1692                             NotificationCategory.missingCoverage);
1693             // skip weLost, englishChanged, changedOldValue, abstained
1694         }
1695         return choiceSet;
1696     }
1697 
getPriorityItemsSummaryCategories( Organization org)1698     public static EnumSet<NotificationCategory> getPriorityItemsSummaryCategories(
1699             Organization org) {
1700         EnumSet<NotificationCategory> set = getDashboardNotificationCategories(org);
1701         set.remove(NotificationCategory.abstained);
1702         return set;
1703     }
1704 
getLocaleCompletionCategories()1705     public static EnumSet<NotificationCategory> getLocaleCompletionCategories() {
1706         return localeCompletionCategories;
1707     }
1708 
1709     public interface LocaleBaselineCount {
getBaselineProblemCount(CLDRLocale cldrLocale)1710         int getBaselineProblemCount(CLDRLocale cldrLocale) throws ExecutionException;
1711     }
1712 
1713     private boolean summarizeAllLocales = false;
1714 
setSummarizeAllLocales(boolean b)1715     public void setSummarizeAllLocales(boolean b) {
1716         summarizeAllLocales = b;
1717     }
1718 }
1719