• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanodash / 30430669996

29 Jul 2026 07:10AM UTC coverage: 34.372% (+0.5%) from 33.91%
30430669996

push

github

web-flow
Merge pull request #569 from knowledgepixels/270-nanopub-not-found-handling

feat: add a "nanopub not found" page (#270)

2460 of 7910 branches covered (31.1%)

Branch coverage included in aggregate %.

4801 of 13215 relevant lines covered (36.33%)

5.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

62.71
src/main/java/com/knowledgepixels/nanodash/Utils.java
1
package com.knowledgepixels.nanodash;
2

3
import com.google.common.hash.Hashing;
4
import com.knowledgepixels.nanodash.domain.User;
5
import net.trustyuri.TrustyUriUtils;
6
import org.apache.commons.codec.Charsets;
7
import org.apache.commons.lang.StringUtils;
8
import org.apache.http.client.utils.URIBuilder;
9
import org.apache.wicket.markup.html.link.ExternalLink;
10
import org.apache.wicket.model.IModel;
11
import org.apache.wicket.request.Url;
12
import org.apache.wicket.request.cycle.RequestCycle;
13
import org.apache.wicket.request.mapper.parameter.PageParameters;
14
import org.apache.wicket.util.string.StringValue;
15
import org.apache.wicket.util.string.Strings;
16
import org.eclipse.rdf4j.model.IRI;
17
import org.eclipse.rdf4j.model.Literal;
18
import org.eclipse.rdf4j.model.Statement;
19
import org.eclipse.rdf4j.model.ValueFactory;
20
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
21
import org.eclipse.rdf4j.model.util.Literals;
22
import org.eclipse.rdf4j.model.vocabulary.FOAF;
23
import org.eclipse.rdf4j.model.vocabulary.XSD;
24
import org.nanopub.Nanopub;
25
import org.nanopub.NanopubUtils;
26
import org.nanopub.extra.security.KeyDeclaration;
27
import org.nanopub.extra.security.MalformedCryptoElementException;
28
import org.nanopub.extra.security.NanopubSignatureElement;
29
import org.nanopub.extra.security.SignatureUtils;
30
import org.nanopub.extra.server.GetNanopub;
31
import org.nanopub.extra.server.NanopubServerUtils;
32
import org.nanopub.extra.services.ApiResponseEntry;
33
import org.nanopub.extra.services.NotEnoughAPIInstancesException;
34
import org.nanopub.extra.services.QueryCall;
35
import org.nanopub.extra.setting.IntroNanopub;
36
import org.nanopub.vocabulary.FIP;
37
import org.nanopub.vocabulary.NPX;
38
import org.owasp.html.HtmlPolicyBuilder;
39
import org.owasp.html.PolicyFactory;
40
import org.slf4j.Logger;
41
import org.slf4j.LoggerFactory;
42
import org.wicketstuff.select2.Select2Choice;
43

44
import java.io.Serializable;
45
import java.net.URISyntaxException;
46
import java.net.URLDecoder;
47
import java.net.URLEncoder;
48
import java.nio.charset.StandardCharsets;
49
import java.time.LocalDateTime;
50
import java.time.OffsetDateTime;
51
import java.time.format.DateTimeFormatter;
52
import java.time.format.DateTimeParseException;
53
import java.util.*;
54
import java.util.concurrent.ConcurrentHashMap;
55
import java.util.regex.Pattern;
56

57
import static java.nio.charset.StandardCharsets.UTF_8;
58

59
/**
60
 * Utility class providing various helper methods for handling nanopublications, URIs, and other related functionalities.
61
 */
62
public class Utils {
63

64
    private Utils() {
65
    }  // no instances allowed
66

67
    /**
68
     * ValueFactory instance for creating RDF model objects.
69
     */
70
    public static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
71
    private static final Logger logger = LoggerFactory.getLogger(Utils.class);
9✔
72
    private static final Pattern LEADING_TAG = Pattern.compile("^\\s*<(p|div|span|img|pre)(\\s|>|/).*", Pattern.CASE_INSENSITIVE);
12✔
73
    private static final String DEFAULT_MAIN_QUERY_URL = "https://query.knowledgepixels.com/";
74
    private static final String DEFAULT_MAIN_REGISTRY_URL = "https://registry.knowledgepixels.com/";
75

76
    /**
77
     * Generates a short name from a given IRI object.
78
     *
79
     * @param uri the IRI object
80
     * @return a short representation of the URI
81
     */
82
    public static String getShortNameFromURI(IRI uri) {
83
        return getShortNameFromURI(uri.stringValue());
12✔
84
    }
85

86
    /**
87
     * Generates a short name from a given URI string.
88
     *
89
     * @param uri the URI string
90
     * @return a short representation of the URI
91
     */
92
    public static String getShortNameFromURI(String uri) {
93
        if (uri.startsWith("https://doi.org/") || uri.startsWith("http://dx.doi.org/")) {
24✔
94
            return uri.replaceFirst("^https?://(dx\\.)?doi.org/", "doi:");
15✔
95
        }
96
        uri = uri.replaceFirst("\\?.*$", "");
15✔
97
        uri = uri.replaceFirst("[/#]$", "");
15✔
98
        uri = uri.replaceFirst("^.*[/#]([^/#]*)[/#]([0-9]+)$", "$1/$2");
15✔
99
        if (uri.contains("#")) {
12✔
100
            uri = uri.replaceFirst("^.*#(.*[^0-9].*)$", "$1");
18✔
101
        } else {
102
            uri = uri.replaceFirst("^.*/([^/]*[^0-9/][^/]*)$", "$1");
15✔
103
        }
104
        uri = uri.replaceFirst("((^|[^A-Za-z0-9\\-_])RA[A-Za-z0-9\\-_]{8})[A-Za-z0-9\\-_]{35}$", "$1");
15✔
105
        uri = uri.replaceFirst("(^|[^A-Za-z0-9\\-_])RA[A-Za-z0-9\\-_]{43}[^A-Za-z0-9\\-_](.+)$", "$2");
15✔
106
        uri = URLDecoder.decode(uri, UTF_8);
12✔
107
        return uri;
6✔
108
    }
109

110
    /**
111
     * Generates a short nanopublication ID from a given nanopublication ID or URI.
112
     *
113
     * @param npId the nanopublication ID or URI
114
     * @return the first 10 characters of the artifact code
115
     */
116
    public static String getShortNanopubId(Object npId) {
117
        return TrustyUriUtils.getArtifactCode(npId.toString()).substring(0, 10);
21✔
118
    }
119

120
    // Concurrent, as retrieval also happens on background threads (see NanopubLookup):
121
    private static Map<String, Nanopub> nanopubs = new ConcurrentHashMap<>();
12✔
122

123
    /**
124
     * Adds a nanopublication to the local cache so it can be retrieved immediately
125
     * without needing to fetch it from the registry.
126
     *
127
     * @param np the nanopublication to cache
128
     */
129
    public static void cacheNanopub(Nanopub np) {
130
        String artifactCode = GetNanopub.getArtifactCode(np.getUri().stringValue()).toString();
×
131
        nanopubs.put(artifactCode, np);
×
132
    }
×
133

134
    /**
135
     * Retrieves a Nanopub object based on the given URI or artifact code.
136
     *
137
     * @param uriOrArtifactCode the URI or artifact code of the nanopublication
138
     * @return the Nanopub object, or null if not found
139
     */
140
    public static Nanopub getNanopub(String uriOrArtifactCode) {
141
        String artifactCode = GetNanopub.getArtifactCode(uriOrArtifactCode).toString();
12✔
142
        if (!nanopubs.containsKey(artifactCode)) {
12✔
143
            for (int i = 0; i < 3; i++) {  // Try 3 times to get nanopub
15!
144
                Nanopub np = GetNanopub.get(artifactCode);
9✔
145
                if (np != null) {
6!
146
                    nanopubs.put(artifactCode, np);
15✔
147
                    break;
3✔
148
                }
149
            }
150
        }
151
        return nanopubs.get(artifactCode);
15✔
152
    }
153

154
    /**
155
     * Strips a sub-IRI of a nanopublication (e.g. an embedded resource IRI like
156
     * {@code <np-uri>/template}) down to the nanopublication ID itself, i.e. up to
157
     * and including the trusty artifact code. An ID without a sub-path is returned
158
     * unchanged.
159
     *
160
     * @param id the ID to strip
161
     * @return the nanopublication ID
162
     */
163
    public static String stripToNanopubId(String id) {
164
        return id.replaceFirst("^(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43})[^A-Za-z0-9-_].*$", "$1");
15✔
165
    }
166

167
    // Wicket URL-encodes the session into a path parameter when the client has not (yet)
168
    // returned a cookie. Harmless for a link the browser follows, poisonous for a URL that
169
    // outlives the request — see absolutePageUrl.
170
    private static final Pattern JSESSIONID_PATTERN = Pattern.compile(";jsessionid=[^?/;]*", Pattern.CASE_INSENSITIVE);
12✔
171

172
    /**
173
     * The absolute URL of a mounted page, for handing to something outside this request:
174
     * embedding in a downloaded file, giving to a third-party service to fetch, or showing
175
     * the user to copy.
176
     *
177
     * <p>Any {@code ;jsessionid=} Wicket added is stripped. Such a URL is not merely ugly —
178
     * it is bound to one visitor's session, so a calendar feed URL carrying one would break
179
     * once the session expired, and would hand out a live session identifier to anyone the
180
     * user shared it with.</p>
181
     *
182
     * @param pageClass the mounted page
183
     * @param params    the page parameters
184
     * @return the full URL, including scheme and host
185
     */
186
    public static String absolutePageUrl(Class<? extends org.apache.wicket.Page> pageClass, PageParameters params) {
187
        RequestCycle cycle = RequestCycle.get();
×
188
        String url = cycle.urlFor(pageClass, params).toString();
×
189
        return stripSessionId(cycle.getUrlRenderer().renderFullUrl(Url.parse(url)));
×
190
    }
191

192
    /**
193
     * Removes a {@code ;jsessionid=...} path parameter from a URL. See
194
     * {@link #absolutePageUrl} for why this must happen before a URL leaves the request.
195
     *
196
     * @param url the URL, possibly session-decorated
197
     * @return the URL without its session id
198
     */
199
    static String stripSessionId(String url) {
200
        return JSESSIONID_PATTERN.matcher(url).replaceAll("");
18✔
201
    }
202

203
    /**
204
     * URL-encodes the string representation of the given object using UTF-8 encoding.
205
     *
206
     * @param o the object to be URL-encoded
207
     * @return the URL-encoded string
208
     */
209
    public static String urlEncode(Object o) {
210
        return URLEncoder.encode((o == null ? "" : o.toString()), Charsets.UTF_8);
27✔
211
    }
212

213
    public static String truncateLabel(String label) {
214
        if (label != null && label.length() > 120) {
×
215
            return label.substring(0, 100) + "...";
×
216
        }
217
        return label;
×
218
    }
219

220
    /**
221
     * Truncates an over-long entity label for display in a link or breadcrumb:
222
     * labels longer than 60 characters are cut to 47 characters with an ellipsis
223
     * ("...") appended, so a single long label (e.g. a full IRI tail) never blows
224
     * up the surrounding UI. Shorter labels are returned unchanged. The full label
225
     * stays available on the entity's own page, which shows it as the title.
226
     *
227
     * @param label the label to truncate, or null
228
     * @return the truncated label, or the original if 60 characters or shorter
229
     */
230
    public static String truncateLinkLabel(String label) {
231
        if (label == null || label.length() <= 60) {
18!
232
            return label;
6✔
233
        }
234
        return label.substring(0, 47).stripTrailing() + "...";
×
235
    }
236

237
    /**
238
     * Builds the HTML body for a menu entry whose label may begin with a leading
239
     * symbol/emoji used as the entry's icon. If {@code label} starts with a token
240
     * of symbol/emoji characters (no letters or digits) followed by whitespace,
241
     * that token is wrapped in the {@code .actionmenu-icon} slot and the remaining
242
     * text follows it (both escaped). Returns {@code null} when there is no such
243
     * leading icon, so callers can fall back to the plain (escaped) label.
244
     *
245
     * @param label the menu entry label
246
     * @return the icon+text HTML body to render with escaping disabled, or null
247
     */
248
    public static String menuEntryIconBodyHtml(String label) {
249
        if (label == null) {
×
250
            return null;
×
251
        }
252
        int sp = -1;
×
253
        for (int i = 0; i < label.length(); i++) {
×
254
            if (Character.isWhitespace(label.charAt(i))) {
×
255
                sp = i;
×
256
                break;
×
257
            }
258
        }
259
        if (sp <= 0) {
×
260
            return null;
×
261
        }
262
        String icon = label.substring(0, sp);
×
263
        String rest = label.substring(sp).replaceFirst("^\\s+", "");
×
264
        if (rest.isEmpty()) {
×
265
            return null;
×
266
        }
267
        // Only a pure symbol/emoji token (no letters or digits) counts as an icon.
268
        if (icon.codePoints().anyMatch(Character::isLetterOrDigit)) {
×
269
            return null;
×
270
        }
271
        return "<span class=\"actionmenu-icon\">" + Strings.escapeMarkup(icon) + "</span>" + Strings.escapeMarkup(rest);
×
272
    }
273

274
    /**
275
     * URL-decodes the string representation of the given object using UTF-8 encoding.
276
     *
277
     * @param o the object to be URL-decoded
278
     * @return the URL-decoded string
279
     */
280
    public static String urlDecode(Object o) {
281
        return URLDecoder.decode((o == null ? "" : o.toString()), Charsets.UTF_8);
27✔
282
    }
283

284
    /**
285
     * Generates a URL with the given base and appends the provided PageParameters as query parameters.
286
     *
287
     * @param base       the base URL
288
     * @param parameters the PageParameters to append
289
     * @return the complete URL with parameters
290
     */
291
    public static String getUrlWithParameters(String base, PageParameters parameters) {
292
        try {
293
            URIBuilder u = new URIBuilder(base);
15✔
294
            for (String key : parameters.getNamedKeys()) {
33✔
295
                for (StringValue value : parameters.getValues(key)) {
36✔
296
                    if (!value.isNull()) {
9!
297
                        u.addParameter(key, value.toString());
18✔
298
                    }
299
                }
3✔
300
            }
3✔
301
            return u.build().toString();
12✔
302
        } catch (URISyntaxException ex) {
3✔
303
            logger.error("Could not build URL with parameters: {} {}", base, parameters, ex);
51✔
304
            return "/";
6✔
305
        }
306
    }
307

308
    /**
309
     * Generates a short name for a public key or public key hash.
310
     *
311
     * @param pubkeyOrPubkeyhash the public key (64 characters) or public key hash (40 characters)
312
     * @return a short representation of the public key or public key hash
313
     */
314
    public static String getShortPubkeyName(String pubkeyOrPubkeyhash) {
315
        if (pubkeyOrPubkeyhash.length() == 64) {
12!
316
            return pubkeyOrPubkeyhash.replaceFirst("^(.{8}).*$", "$1");
×
317
        } else {
318
            return pubkeyOrPubkeyhash.replaceFirst("^(.).{39}(.{5}).*$", "$1..$2..");
15✔
319
        }
320
    }
321

322
    /**
323
     * Generates a short label for a public key or public key hash, including its status (local or approved).
324
     *
325
     * @param pubkeyOrPubkeyhash the public key (64 characters) or public key hash (40 characters)
326
     * @param user               the IRI of the user associated with the public key
327
     * @return a short label indicating the public key and its status
328
     */
329
    public static String getShortPubkeyhashLabel(String pubkeyOrPubkeyhash, IRI user) {
330
        String s = getShortPubkeyName(pubkeyOrPubkeyhash);
×
331
        NanodashSession session = NanodashSession.get();
×
332
        List<String> l = new ArrayList<>();
×
333
        if (pubkeyOrPubkeyhash.equals(session.getPubkeyString()) || pubkeyOrPubkeyhash.equals(session.getPubkeyhash())) {
×
334
            l.add("local");
×
335
        }
336
        // TODO: Make this more efficient:
337
        String hashed = Utils.createSha256HexHash(pubkeyOrPubkeyhash);
×
338
        if (User.getPubkeyhashes(user, true).contains(pubkeyOrPubkeyhash) || User.getPubkeyhashes(user, true).contains(hashed)) {
×
339
            l.add("approved");
×
340
        }
341
        if (!l.isEmpty()) {
×
342
            s += " (" + String.join("/", l) + ")";
×
343
        }
344
        return s;
×
345
    }
346

347
    /**
348
     * Retrieves the name of the public key location based on the public key.
349
     *
350
     * @param pubkeyhash the public key string
351
     * @return the name of the public key location
352
     */
353
    public static String getPubkeyLocationName(String pubkeyhash) {
354
        return getPubkeyLocationName(pubkeyhash, getShortPubkeyName(pubkeyhash));
×
355
    }
356

357
    /**
358
     * Retrieves the name of the public key location, or returns a fallback name if not found.
359
     * If the key location is localhost, it returns "localhost".
360
     *
361
     * @param pubkeyhash the public key string
362
     * @param fallback   the fallback name to return if the key location is not found
363
     * @return the name of the public key location or the fallback name
364
     */
365
    public static String getPubkeyLocationName(String pubkeyhash, String fallback) {
366
        IRI keyLocation = User.getUserData().getKeyLocationForPubkeyHash(pubkeyhash);
×
367
        if (keyLocation == null) {
×
368
            return fallback;
×
369
        }
370
        if (keyLocation.stringValue().equals("http://localhost:37373/")) {
×
371
            return "localhost";
×
372
        }
373
        return keyLocation.stringValue().replaceFirst("https?://(nanobench\\.)?(nanodash\\.(?=.*\\..))?(.*[^/])/?$", "$3");
×
374
    }
375

376
    /**
377
     * Generates a short label for a public key location, including its status (local or approved).
378
     *
379
     * @param pubkeyhash the public key string
380
     * @param user       the IRI of the user associated with the public key
381
     * @return a short label indicating the public key location and its status
382
     */
383
    public static String getShortPubkeyLocationLabel(String pubkeyhash, IRI user) {
384
        String s = getPubkeyLocationName(pubkeyhash);
×
385
        NanodashSession session = NanodashSession.get();
×
386
        List<String> l = new ArrayList<>();
×
387
        if (pubkeyhash.equals(session.getPubkeyhash())) {
×
388
            l.add("local");
×
389
        }
390
        // TODO: Make this more efficient:
391
        if (User.getPubkeyhashes(user, true).contains(pubkeyhash)) {
×
392
            l.add("approved");
×
393
        }
394
        if (!l.isEmpty()) {
×
395
            s += " (" + String.join("/", l) + ")";
×
396
        }
397
        return s;
×
398
    }
399

400
    /**
401
     * Checks if a given public key has a Nanodash location.
402
     * A Nanodash location is identified by specific keywords in the key location.
403
     *
404
     * @param pubkeyhash the public key to check
405
     * @return true if the public key has a Nanodash location, false otherwise
406
     */
407
    public static boolean hasNanodashLocation(String pubkeyhash) {
408
        IRI keyLocation = User.getUserData().getKeyLocationForPubkeyHash(pubkeyhash);
×
409
        if (keyLocation == null) {
×
410
            return true; // potentially a Nanodash location
×
411
        }
412
        if (keyLocation.stringValue().contains("nanodash")) {
×
413
            return true;
×
414
        }
415
        if (keyLocation.stringValue().contains("nanobench")) {
×
416
            return true;
×
417
        }
418
        if (keyLocation.stringValue().contains(":37373")) {
×
419
            return true;
×
420
        }
421
        return false;
×
422
    }
423

424
    /**
425
     * Retrieves the short ORCID ID from an IRI object.
426
     *
427
     * @param orcidIri the IRI object representing the ORCID ID
428
     * @return the short ORCID ID as a string
429
     */
430
    public static String getShortOrcidId(IRI orcidIri) {
431
        return orcidIri.stringValue().replaceFirst("^https://orcid.org/", "");
18✔
432
    }
433

434
    /**
435
     * Retrieves the URI postfix from a given URI object.
436
     *
437
     * @param uri the URI object from which to extract the postfix
438
     * @return the URI postfix as a string
439
     */
440
    public static String getUriPostfix(Object uri) {
441
        String s = uri.toString();
9✔
442
        if (s.contains("#")) {
12✔
443
            return s.replaceFirst("^.*#(.*)$", "$1");
15✔
444
        }
445
        return s.replaceFirst("^.*/(.*)$", "$1");
15✔
446
    }
447

448
    /**
449
     * Retrieves the URI prefix from a given URI object.
450
     *
451
     * @param uri the URI object from which to extract the prefix
452
     * @return the URI prefix as a string
453
     */
454
    public static String getUriPrefix(Object uri) {
455
        String s = uri.toString();
9✔
456
        if (s.contains("#")) {
12✔
457
            return s.replaceFirst("^(.*#).*$", "$1");
15✔
458
        }
459
        return s.replaceFirst("^(.*/).*$", "$1");
15✔
460
    }
461

462
    /**
463
     * Checks if a given string is a valid URI postfix.
464
     * A valid URI postfix does not contain a colon (":").
465
     *
466
     * @param s the string to check
467
     * @return true if the string is a valid URI postfix, false otherwise
468
     */
469
    public static boolean isUriPostfix(String s) {
470
        return !s.contains(":");
24✔
471
    }
472

473
    /**
474
     * Retrieves the location of a given IntroNanopub.
475
     *
476
     * @param inp the IntroNanopub from which to extract the location
477
     * @return the IRI location of the nanopublication, or null if not found
478
     */
479
    public static IRI getLocation(IntroNanopub inp) {
480
        NanopubSignatureElement el = getNanopubSignatureElement(inp);
×
481
        for (KeyDeclaration kd : inp.getKeyDeclarations()) {
×
482
            if (el.getPublicKeyString().equals(kd.getPublicKeyString())) {
×
483
                return kd.getKeyLocation();
×
484
            }
485
        }
×
486
        return null;
×
487
    }
488

489
    /**
490
     * Retrieves the NanopubSignatureElement from a given IntroNanopub.
491
     *
492
     * @param inp the IntroNanopub from which to extract the signature element
493
     * @return the NanopubSignatureElement associated with the nanopublication
494
     */
495
    public static NanopubSignatureElement getNanopubSignatureElement(IntroNanopub inp) {
496
        try {
497
            return SignatureUtils.getSignatureElement(inp.getNanopub());
×
498
        } catch (MalformedCryptoElementException ex) {
×
499
            throw new RuntimeException(ex);
×
500
        }
501
    }
502

503
    /**
504
     * Retrieves a Nanopub object from a given URI if it is a potential Trusty URI.
505
     *
506
     * @param uri the URI to check and retrieve the Nanopub from
507
     * @return the Nanopub object if found, or null if not a known nanopublication
508
     */
509
    public static Nanopub getAsNanopub(String uri) {
510
        if (uri == null) {
6!
511
            return null;
×
512
        }
513
        if (TrustyUriUtils.isPotentialTrustyUri(uri)) {
9!
514
            try {
515
                return Utils.getNanopub(uri);
9✔
516
            } catch (Exception ex) {
×
517
                logger.error("The given URI is not a known nanopublication: {}", uri, ex);
×
518
            }
519
        }
520
        return null;
×
521
    }
522

523
    private static final PolicyFactory htmlSanitizePolicy = new HtmlPolicyBuilder().allowCommonBlockElements().allowCommonInlineFormattingElements().allowUrlProtocols("https", "http", "mailto").allowElements("a").allowAttributes("href").onElements("a").allowElements("img").allowAttributes("src").onElements("img").allowElements("pre").requireRelNofollowOnLinks().toFactory();
216✔
524

525
    /**
526
     * Sanitizes raw HTML input to ensure safe rendering.
527
     *
528
     * @param rawHtml the raw HTML input to sanitize
529
     * @return sanitized HTML string
530
     */
531
    public static String sanitizeHtml(String rawHtml) {
532
        return htmlSanitizePolicy.sanitize(rawHtml);
12✔
533
    }
534

535
    /**
536
     * Checks if a given string is likely to be HTML content.
537
     *
538
     * @param value the string to check
539
     * @return true if the given string is HTML content, false otherwise
540
     */
541
    public static boolean looksLikeHtml(String value) {
542
        return LEADING_TAG.matcher(value).find();
15✔
543
    }
544

545
    public static boolean isDate(String value) {
546
        return isDateLiteral(value) || isDateTimeLiteral(value);
30✔
547
    }
548

549
    public static boolean isDateLiteral(String value) {
550
        if (value == null || value.isBlank() || value.contains("T")) {
27✔
551
            return false;
6✔
552
        }
553
        try {
554
            DateTimeFormatter.ISO_DATE.parse(value);
12✔
555
            return true;
6✔
556
        } catch (DateTimeParseException ex) {
3✔
557
            return false;
6✔
558
        }
559
    }
560

561
    /**
562
     * Checks whether a (raw query-result) string looks like an ISO-8601 date-time literal.
563
     *
564
     * @param value the string to check
565
     * @return true if the string parses as a xsd:dateTime-style value
566
     */
567
    public static boolean isDateTimeLiteral(String value) {
568
        if (value == null || value.isBlank()) {
15✔
569
            return false;
6✔
570
        }
571

572
        try {
573
            OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME);
12✔
574
            return true;
6✔
575
        } catch (DateTimeParseException ignored) {
3✔
576
        }
577

578
        try {
579
            LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME);
12✔
580
            return true;
6✔
581
        } catch (DateTimeParseException ignored) {
3✔
582
            return false;
6✔
583
        }
584
    }
585

586
    /**
587
     * Renders a {@code <time>} element for an ISO-8601 date-time value. The machine-readable
588
     * value goes in the {@code datetime} attribute; client-side script (nanodash.js) rewrites
589
     * the visible text to a relative form ("10 minutes ago") in the viewer's local timezone and
590
     * puts the absolute date-time in the tooltip. If script does not run, {@code fallbackText}
591
     * remains visible.
592
     *
593
     * @param isoValue     the ISO-8601 date-time string (machine-readable)
594
     * @param fallbackText the human-readable text shown when script is unavailable
595
     * @return an HTML {@code <time>} element string (caller must render with escaping disabled)
596
     */
597
    public static String friendlyDateHtml(String isoValue, String fallbackText) {
598
        return "<time class=\"friendly-date\" datetime=\"" + Strings.escapeMarkup(isoValue) + "\">" + Strings.escapeMarkup(fallbackText) + "</time>";
×
599
    }
600

601
    /**
602
     * Converts PageParameters to a URL-encoded string representation.
603
     *
604
     * @param params the PageParameters to convert
605
     * @return a string representation of the parameters in URL-encoded format
606
     */
607
    public static String getPageParametersAsString(PageParameters params) {
608
        String s = "";
6✔
609
        for (String n : params.getNamedKeys()) {
33✔
610
            if (!s.isEmpty()) {
9✔
611
                s += "&";
9✔
612
            }
613
            s += n + "=" + URLEncoder.encode(params.get(n).toString(), Charsets.UTF_8);
30✔
614
        }
3✔
615
        return s;
6✔
616
    }
617

618
    /**
619
     * Sets a minimal escape markup function for a Select2Choice component.
620
     * This function replaces certain characters and formats the display of choices.
621
     *
622
     * @param selectItem the Select2Choice component to set the escape markup for
623
     */
624
    public static void setSelect2ChoiceMinimalEscapeMarkup(Select2Choice<?> selectItem) {
625
        selectItem.getSettings().setEscapeMarkup("function(markup) {" + "return markup" + ".replaceAll('<','&lt;').replaceAll('>', '&gt;')" + ".replace(/^(.*?) - /, '<span class=\"term\">$1</span><br>')" + ".replace(/\\((https?:[\\S]+)\\)$/, '<br><code>$1</code>')" + ".replace(/^([^<].*)$/, '<span class=\"term\">$1</span>')" + ";}");
15✔
626
    }
3✔
627

628
    /**
629
     * Checks if a nanopublication is of a specific class.
630
     *
631
     * @param np       the nanopublication to check
632
     * @param classIri the IRI of the class to check against
633
     * @return true if the nanopublication is of the specified class, false otherwise
634
     */
635
    public static boolean isNanopubOfClass(Nanopub np, IRI classIri) {
636
        return NanopubUtils.getTypes(np).contains(classIri);
15✔
637
    }
638

639
    /**
640
     * Checks if a nanopublication uses a specific predicate in its assertion.
641
     *
642
     * @param np           the nanopublication to check
643
     * @param predicateIri the IRI of the predicate to look for
644
     * @return true if the predicate is used in the assertion, false otherwise
645
     */
646
    public static boolean usesPredicateInAssertion(Nanopub np, IRI predicateIri) {
647
        for (Statement st : np.getAssertion()) {
33✔
648
            if (predicateIri.equals(st.getPredicate())) {
15✔
649
                return true;
6✔
650
            }
651
        }
3✔
652
        return false;
6✔
653
    }
654

655
    /**
656
     * Retrieves a map of FOAF names from the nanopublication's pubinfo.
657
     *
658
     * @param np the nanopublication from which to extract FOAF names
659
     * @return a map where keys are subjects and values are FOAF names
660
     */
661
    public static Map<String, String> getFoafNameMap(Nanopub np) {
662
        Map<String, String> foafNameMap = new HashMap<>();
12✔
663
        for (Statement st : np.getPubinfo()) {
33✔
664
            if (st.getPredicate().equals(FOAF.NAME) && st.getObject() instanceof Literal objL) {
42✔
665
                foafNameMap.put(st.getSubject().stringValue(), objL.stringValue());
24✔
666
            }
667
        }
3✔
668
        return foafNameMap;
6✔
669
    }
670

671
    /**
672
     * Creates an SHA-256 hash of the string representation of an object and returns it as a hexadecimal string.
673
     *
674
     * @param obj the object to hash
675
     * @return the SHA-256 hash of the object's string representation in hexadecimal format
676
     */
677
    public static String createSha256HexHash(Object obj) {
678
        return Hashing.sha256().hashString(obj.toString(), StandardCharsets.UTF_8).toString();
21✔
679
    }
680

681
    /**
682
     * Gets the types of a nanopublication.
683
     *
684
     * @param np the nanopublication from which to extract types
685
     * @return a list of IRI types associated with the nanopublication
686
     */
687
    public static List<IRI> getTypes(Nanopub np) {
688
        List<IRI> l = new ArrayList<>();
12✔
689
        for (IRI t : NanopubUtils.getTypes(np)) {
33✔
690
            if (t.equals(FIP.AVAILABLE_FAIR_ENABLING_RESOURCE)) {
12✔
691
                continue;
3✔
692
            }
693
            if (t.equals(FIP.FAIR_ENABLING_RESOURCE_TO_BE_DEVELOPED)) {
12✔
694
                continue;
3✔
695
            }
696
            if (t.equals(FIP.AVAILABLE_FAIR_SUPPORTING_RESOURCE)) {
12!
697
                continue;
×
698
            }
699
            if (t.equals(FIP.FAIR_SUPPORTING_RESOURCE_TO_BE_DEVELOPED)) {
12!
700
                continue;
×
701
            }
702
            l.add(t);
12✔
703
        }
3✔
704
        return l;
6✔
705
    }
706

707
    /**
708
     * Gets a label for a type IRI.
709
     *
710
     * @param typeIri the IRI of the type
711
     * @return a label for the type, potentially truncated
712
     */
713
    public static String getTypeLabel(IRI typeIri) {
714
        if (typeIri.equals(FIP.FAIR_ENABLING_RESOURCE)) {
12✔
715
            return "FER";
6✔
716
        }
717
        if (typeIri.equals(FIP.FAIR_SUPPORTING_RESOURCE)) {
12✔
718
            return "FSR";
6✔
719
        }
720
        if (typeIri.equals(FIP.FAIR_IMPLEMENTATION_PROFILE)) {
12✔
721
            return "FIP";
6✔
722
        }
723
        if (typeIri.equals(NPX.DECLARED_BY)) {
12✔
724
            return "user intro";
6✔
725
        }
726
        String l = typeIri.stringValue();
9✔
727
        l = l.replaceFirst("^.*[/#]([^/#]+)[/#]?$", "$1");
15✔
728
        l = l.replaceFirst("^(.+)Nanopub$", "$1");
15✔
729
        if (l.length() > 25) {
12✔
730
            l = l.substring(0, 20) + "...";
18✔
731
        }
732
        return l;
6✔
733
    }
734

735
    /**
736
     * Gets a label for a URI.
737
     *
738
     * @param uri the URI to get the label from
739
     * @return a label for the URI, potentially truncated
740
     */
741
    public static String getUriLabel(String uri) {
742
        if (uri == null) {
6✔
743
            return "";
6✔
744
        }
745
        String uriLabel = uri;
6✔
746
        if (uriLabel.matches(".*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{43}([^A-Za-z0-9-_].*)?")) {
12✔
747
            String newUriLabel = uriLabel.replaceFirst("(.*[^A-Za-z0-9-_]RA[A-Za-z0-9-_]{8})[A-Za-z0-9-_]{35}([^A-Za-z0-9-_].*)?", "$1...$2");
15✔
748
            if (newUriLabel.length() <= 70) {
12!
749
                return newUriLabel;
6✔
750
            }
751
        }
752
        if (uriLabel.length() > 70) {
12✔
753
            return uri.substring(0, 30) + "..." + uri.substring(uri.length() - 30);
36✔
754
        }
755
        return uriLabel;
6✔
756
    }
757

758
    /**
759
     * Gets an ExternalLink with a URI label.
760
     *
761
     * @param markupId the markup ID for the link
762
     * @param uri      the URI to link to
763
     * @return an ExternalLink with the URI label
764
     */
765
    public static ExternalLink getUriLink(String markupId, String uri) {
766
        return new ExternalLink(markupId, (Utils.isLocalURI(uri) ? "" : uri), getUriLabel(uri));
39✔
767
    }
768

769
    /**
770
     * Gets an ExternalLink with a model for the URI label.
771
     *
772
     * @param markupId the markup ID for the link
773
     * @param model    the model containing the URI
774
     * @return an ExternalLink with the URI label
775
     */
776
    public static ExternalLink getUriLink(String markupId, IModel<String> model) {
777
        return new ExternalLink(markupId, model, new UriLabelModel(model));
30✔
778
    }
779

780
    private static class UriLabelModel implements IModel<String> {
781

782
        private IModel<String> uriModel;
783

784
        public UriLabelModel(IModel<String> uriModel) {
6✔
785
            this.uriModel = uriModel;
9✔
786
        }
3✔
787

788
        @Override
789
        public String getObject() {
790
            return getUriLabel(uriModel.getObject());
18✔
791
        }
792

793
    }
794

795
    /**
796
     * Creates a sublist from a list based on the specified indices.
797
     *
798
     * @param list      the list from which to create the sublist
799
     * @param fromIndex the starting index (inclusive) for the sublist
800
     * @param toIndex   the ending index (exclusive) for the sublist
801
     * @param <E>       the type of elements in the list
802
     * @return an ArrayList containing the elements from the specified range
803
     */
804
    public static <E> ArrayList<E> subList(List<E> list, long fromIndex, long toIndex) {
805
        // So the resulting list is serializable:
806
        return new ArrayList<E>(list.subList((int) fromIndex, (int) toIndex));
×
807
    }
808

809
    /**
810
     * Creates a sublist from an array based on the specified indices.
811
     *
812
     * @param array     the array from which to create the sublist
813
     * @param fromIndex the starting index (inclusive) for the sublist
814
     * @param toIndex   the ending index (exclusive) for the sublist
815
     * @param <E>       the type of elements in the array
816
     * @return an ArrayList containing the elements from the specified range
817
     */
818
    public static <E> ArrayList<E> subList(E[] array, long fromIndex, long toIndex) {
819
        return subList(Arrays.asList(array), fromIndex, toIndex);
×
820
    }
821

822
    /**
823
     * Comparator for sorting ApiResponseEntry objects based on a specified field.
824
     */
825
    // TODO Move this to ApiResponseEntry class?
826
    public static class ApiResponseEntrySorter implements Comparator<ApiResponseEntry>, Serializable {
827

828
        private String field;
829
        private boolean descending;
830

831
        /**
832
         * Constructor for ApiResponseEntrySorter.
833
         *
834
         * @param field      the field to sort by
835
         * @param descending if true, sorts in descending order; if false, sorts in ascending order
836
         */
837
        public ApiResponseEntrySorter(String field, boolean descending) {
×
838
            this.field = field;
×
839
            this.descending = descending;
×
840
        }
×
841

842
        /**
843
         * Compares two ApiResponseEntry objects based on the specified field.
844
         *
845
         * @param o1 the first object to be compared.
846
         * @param o2 the second object to be compared.
847
         * @return a negative integer, zero, or a positive integer as the first argument is less than, equal to, or greater than the second.
848
         */
849
        @Override
850
        public int compare(ApiResponseEntry o1, ApiResponseEntry o2) {
851
            if (descending) {
×
852
                return o2.get(field).compareTo(o1.get(field));
×
853
            } else {
854
                return o1.get(field).compareTo(o2.get(field));
×
855
            }
856
        }
857

858
    }
859

860
    /**
861
     * MIME type for TriG RDF format.
862
     */
863
    public static final String TYPE_TRIG = "application/trig";
864

865
    /**
866
     * MIME type for Jelly RDF format.
867
     */
868
    public static final String TYPE_JELLY = "application/x-jelly-rdf";
869

870
    /**
871
     * MIME type for JSON-LD format.
872
     */
873
    public static final String TYPE_JSONLD = "application/ld+json";
874

875
    /**
876
     * MIME type for N-Quads format.
877
     */
878
    public static final String TYPE_NQUADS = "application/n-quads";
879

880
    /**
881
     * MIME type for Trix format.
882
     */
883
    public static final String TYPE_TRIX = "application/trix";
884

885
    /**
886
     * MIME type for HTML format.
887
     */
888
    public static final String TYPE_HTML = "text/html";
889

890
    /**
891
     * Comma-separated list of supported MIME types for nanopublications.
892
     */
893
    public static final String SUPPORTED_TYPES = TYPE_TRIG + "," + TYPE_JELLY + "," + TYPE_JSONLD + "," + TYPE_NQUADS + "," + TYPE_TRIX + "," + TYPE_HTML;
894

895
    /**
896
     * List of supported MIME types for nanopublications.
897
     */
898
    public static final List<String> SUPPORTED_TYPES_LIST = Arrays.asList(StringUtils.split(SUPPORTED_TYPES, ','));
18✔
899

900
    private static volatile String resolvedMainRegistryUrl;
901
    private static volatile String resolvedMainQueryUrl;
902

903
    /**
904
     * Eagerly resolves the main registry and query URLs. Call at application startup
905
     * so the (potentially slow) first-time discovery does not happen during a user request.
906
     */
907
    public static void initMainUrls() {
908
        getMainRegistryUrl();
6✔
909
        getMainQueryUrl();
6✔
910
    }
3✔
911

912
    /**
913
     * Returns the URL of the main Nanopub Registry for this nanodash instance.
914
     * <p>
915
     * If {@code NANODASH_MAIN_REGISTRY} is set and matches an entry in the library's
916
     * discovered registry instance list, that URL is used. Otherwise the first entry
917
     * of the library list is used. If the library list is empty, the env var value
918
     * (or built-in default) is used unvalidated. The result is cached for the JVM lifetime.
919
     *
920
     * @return Nanopub Registry URL (with trailing slash)
921
     */
922
    public static String getMainRegistryUrl() {
923
        if (resolvedMainRegistryUrl == null) {
6✔
924
            synchronized (Utils.class) {
12✔
925
                if (resolvedMainRegistryUrl == null) {
6!
926
                    resolvedMainRegistryUrl = resolveMainRegistryUrl();
6✔
927
                }
928
            }
9✔
929
        }
930
        return resolvedMainRegistryUrl;
6✔
931
    }
932

933
    /**
934
     * Returns the URL of the main Nanopub Query API for this nanodash instance.
935
     * <p>
936
     * If {@code NANODASH_MAIN_QUERY} is set and matches an entry in the library's
937
     * discovered query instance list, that URL is used. Otherwise the first entry
938
     * of the library list is used. If the library list is empty, the env var value
939
     * (or built-in default) is used unvalidated. The result is cached for the JVM lifetime.
940
     *
941
     * @return Nanopub Query URL (with trailing slash)
942
     */
943
    public static String getMainQueryUrl() {
944
        if (resolvedMainQueryUrl == null) {
6✔
945
            synchronized (Utils.class) {
12✔
946
                if (resolvedMainQueryUrl == null) {
6!
947
                    resolvedMainQueryUrl = resolveMainQueryUrl();
6✔
948
                }
949
            }
9✔
950
        }
951
        return resolvedMainQueryUrl;
6✔
952
    }
953

954
    private static String resolveMainRegistryUrl() {
955
        String envValue = trimToNull(System.getenv("NANODASH_MAIN_REGISTRY"));
12✔
956
        List<String> instances;
957
        try {
958
            instances = NanopubServerUtils.getRegistryServerList();
6✔
959
        } catch (Exception ex) {
×
960
            logger.warn("Could not retrieve registry instance list from nanopub library: {}", ex.toString());
×
961
            instances = Collections.emptyList();
×
962
        }
3✔
963
        return resolveMainUrl("NANODASH_MAIN_REGISTRY", envValue, instances, DEFAULT_MAIN_REGISTRY_URL);
18✔
964
    }
965

966
    private static String resolveMainQueryUrl() {
967
        String envValue = trimToNull(System.getenv("NANODASH_MAIN_QUERY"));
12✔
968
        List<String> instances;
969
        try {
970
            instances = QueryCall.getApiInstances();
6✔
971
        } catch (NotEnoughAPIInstancesException ex) {
×
972
            logger.warn("Nanopub library reports not enough query API instances available: {}", ex.toString());
×
973
            instances = Collections.emptyList();
×
974
        } catch (Exception ex) {
×
975
            logger.warn("Could not retrieve query instance list from nanopub library: {}", ex.toString());
×
976
            instances = Collections.emptyList();
×
977
        }
3✔
978
        return resolveMainUrl("NANODASH_MAIN_QUERY", envValue, instances, DEFAULT_MAIN_QUERY_URL);
18✔
979
    }
980

981
    private static String resolveMainUrl(String envVarName, String envValue, List<String> instances, String builtInDefault) {
982
        if (envValue != null) {
6!
983
            if (containsNormalized(instances, envValue)) {
×
984
                logger.info("Using main URL from {} (validated against library instance list): {}", envVarName, envValue);
×
985
                return ensureTrailingSlash(envValue);
×
986
            }
987
            if (instances.isEmpty()) {
×
988
                logger.warn("Library instance list is empty; using {} unvalidated: {}", envVarName, envValue);
×
989
                return ensureTrailingSlash(envValue);
×
990
            }
991
            logger.warn("{}={} is not in the library instance list {}; falling back to first library instance", envVarName, envValue, instances);
×
992
            return ensureTrailingSlash(instances.get(0));
×
993
        }
994
        if (!instances.isEmpty()) {
9!
995
            String first = instances.get(0);
15✔
996
            logger.info("{} not set; using first library instance: {}", envVarName, first);
15✔
997
            return ensureTrailingSlash(first);
9✔
998
        }
999
        logger.warn("{} not set and library instance list is empty; using built-in default: {}", envVarName, builtInDefault);
×
1000
        return builtInDefault;
×
1001
    }
1002

1003
    private static boolean containsNormalized(List<String> urls, String target) {
1004
        String normTarget = normalizeUrl(target);
×
1005
        for (String url : urls) {
×
1006
            if (normalizeUrl(url).equals(normTarget)) {
×
1007
                return true;
×
1008
            }
1009
        }
×
1010
        return false;
×
1011
    }
1012

1013
    private static String normalizeUrl(String url) {
1014
        if (url == null) {
×
1015
            return "";
×
1016
        }
1017
        return url.trim().replaceFirst("/+$", "").toLowerCase(Locale.ROOT);
×
1018
    }
1019

1020
    private static String ensureTrailingSlash(String url) {
1021
        return url.endsWith("/") ? url : url + "/";
21!
1022
    }
1023

1024
    private static String trimToNull(String s) {
1025
        if (s == null) {
6!
1026
            return null;
6✔
1027
        }
1028
        s = s.trim();
×
1029
        return s.isEmpty() ? null : s;
×
1030
    }
1031

1032
    private static final String PLAIN_LITERAL_PATTERN = "^\"(([^\\\\\\\"]|\\\\\\\\|\\\\\")*)\"";
1033
    private static final String LANGTAG_LITERAL_PATTERN = "^\"(([^\\\\\\\"]|\\\\\\\\|\\\\\")*)\"@([0-9a-zA-Z-]{2,})$";
1034
    private static final String DATATYPE_LITERAL_PATTERN = "^\"(([^\\\\\\\"]|\\\\\\\\|\\\\\")*)\"\\^\\^<([^ ><\"^]+)>";
1035

1036
    /**
1037
     * Checks whether string is valid literal serialization.
1038
     *
1039
     * @param literalString the literal string
1040
     * @return true if valid
1041
     */
1042
    public static boolean isValidLiteralSerialization(String literalString) {
1043
        if (literalString.matches(PLAIN_LITERAL_PATTERN)) {
12✔
1044
            return true;
6✔
1045
        } else if (literalString.matches(LANGTAG_LITERAL_PATTERN)) {
12✔
1046
            return true;
6✔
1047
        } else if (literalString.matches(DATATYPE_LITERAL_PATTERN)) {
12✔
1048
            return true;
6✔
1049
        }
1050
        return false;
6✔
1051
    }
1052

1053
    /**
1054
     * Returns a serialized version of the literal.
1055
     *
1056
     * @param literal the literal
1057
     * @return the String serialization of the literal
1058
     */
1059
    public static String getSerializedLiteral(Literal literal) {
1060
        if (literal.getLanguage().isPresent()) {
12✔
1061
            return "\"" + getEscapedLiteralString(literal.stringValue()) + "\"@" + Literals.normalizeLanguageTag(literal.getLanguage().get());
30✔
1062
        } else if (literal.getDatatype().equals(XSD.STRING)) {
15✔
1063
            return "\"" + getEscapedLiteralString(literal.stringValue()) + "\"";
15✔
1064
        } else {
1065
            return "\"" + getEscapedLiteralString(literal.stringValue()) + "\"^^<" + literal.getDatatype() + ">";
24✔
1066
        }
1067
    }
1068

1069
    /**
1070
     * Parses a serialized literal into a Literal object.
1071
     *
1072
     * @param serializedLiteral The serialized String of the literal
1073
     * @return The parse Literal object
1074
     */
1075
    public static Literal getParsedLiteral(String serializedLiteral) {
1076
        if (serializedLiteral.matches(PLAIN_LITERAL_PATTERN)) {
12✔
1077
            return vf.createLiteral(getUnescapedLiteralString(serializedLiteral.replaceFirst(PLAIN_LITERAL_PATTERN, "$1")));
24✔
1078
        } else if (serializedLiteral.matches(LANGTAG_LITERAL_PATTERN)) {
12✔
1079
            String langtag = serializedLiteral.replaceFirst(LANGTAG_LITERAL_PATTERN, "$3");
15✔
1080
            return vf.createLiteral(getUnescapedLiteralString(serializedLiteral.replaceFirst(LANGTAG_LITERAL_PATTERN, "$1")), langtag);
27✔
1081
        } else if (serializedLiteral.matches(DATATYPE_LITERAL_PATTERN)) {
12✔
1082
            IRI datatype = vf.createIRI(serializedLiteral.replaceFirst(DATATYPE_LITERAL_PATTERN, "$3"));
21✔
1083
            return vf.createLiteral(getUnescapedLiteralString(serializedLiteral.replaceFirst(DATATYPE_LITERAL_PATTERN, "$1")), datatype);
27✔
1084
        }
1085
        throw new IllegalArgumentException("Not a valid literal serialization: " + serializedLiteral);
18✔
1086
    }
1087

1088
    /**
1089
     * Escapes quotes (") and slashes (/) of a literal string.
1090
     *
1091
     * @param unescapedString un-escaped string
1092
     * @return escaped string
1093
     */
1094
    public static String getEscapedLiteralString(String unescapedString) {
1095
        return unescapedString.replaceAll("\\\\", "\\\\\\\\").replaceAll("\"", "\\\"");
24✔
1096
    }
1097

1098
    /**
1099
     * Un-escapes quotes (") and slashes (/) of a literal string.
1100
     *
1101
     * @param escapedString escaped string
1102
     * @return un-escaped string
1103
     */
1104
    public static String getUnescapedLiteralString(String escapedString) {
1105
        return escapedString.replaceAll("\\\\(\\\\|\\\")", "$1");
15✔
1106
    }
1107

1108
    /**
1109
     * Checks if a given IRI is a local URI.
1110
     *
1111
     * @param uri the IRI to check
1112
     * @return true if the IRI is a local URI, false otherwise
1113
     */
1114
    public static boolean isLocalURI(IRI uri) {
1115
        return uri != null && isLocalURI(uri.stringValue());
30✔
1116
    }
1117

1118
    /**
1119
     * Checks if a given string is a local URI.
1120
     *
1121
     * @param uriAsString the string to check
1122
     * @return true if the string is a local URI, false otherwise
1123
     */
1124
    public static boolean isLocalURI(String uriAsString) {
1125
        return !uriAsString.isBlank() && uriAsString.startsWith(LocalUri.PREFIX);
33✔
1126
    }
1127

1128
    public static String unescapeMultiValue(String s) {
1129
        StringBuilder sb = new StringBuilder();
12✔
1130
        for (int i = 0; i < s.length(); i++) {
24✔
1131
            if (s.charAt(i) == '\\' && i + 1 < s.length()) {
33✔
1132
                char next = s.charAt(i + 1);
18✔
1133
                if (next == 'n') {
9✔
1134
                    sb.append('\n');
15✔
1135
                } else if (next == '\\') {
9!
1136
                    sb.append('\\');
15✔
1137
                } else {
1138
                    sb.append(next);
×
1139
                }
1140
                i++;
3✔
1141
            } else {
3✔
1142
                sb.append(s.charAt(i));
18✔
1143
            }
1144
        }
1145
        return sb.toString();
9✔
1146
    }
1147

1148
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc