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

knowledgepixels / nanodash / 26283046198

22 May 2026 10:40AM UTC coverage: 20.906% (+0.2%) from 20.748%
26283046198

Pull #468

github

web-flow
Merge ae71432d3 into 65b0c8452
Pull Request #468: Source space data from nanopub-query spaces repo

1040 of 6290 branches covered (16.53%)

Branch coverage included in aggregate %.

2672 of 11466 relevant lines covered (23.3%)

3.33 hits per line

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

31.75
src/main/java/com/knowledgepixels/nanodash/domain/Space.java
1
package com.knowledgepixels.nanodash.domain;
2

3
import com.knowledgepixels.nanodash.*;
4
import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS;
5
import jakarta.xml.bind.DatatypeConverter;
6
import org.eclipse.rdf4j.model.IRI;
7
import org.eclipse.rdf4j.model.Statement;
8
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
9
import org.eclipse.rdf4j.model.vocabulary.OWL;
10
import org.nanopub.Nanopub;
11
import org.nanopub.extra.services.ApiResponseEntry;
12
import org.nanopub.vocabulary.NTEMPLATE;
13
import org.slf4j.Logger;
14
import org.slf4j.LoggerFactory;
15

16
import java.io.Serializable;
17
import java.util.*;
18
import java.util.concurrent.Future;
19
import java.util.concurrent.TimeUnit;
20

21
/**
22
 * Class representing a "Space", which can be any kind of collaborative unit, like a project, group, or event.
23
 */
24
public class Space extends AbstractResourceWithProfile {
25

26
    private static final Logger logger = LoggerFactory.getLogger(Space.class);
9✔
27

28
    @Override
29
    public boolean isDataInitialized() {
30
        triggerDataUpdate();
×
31
        return dataInitialized && super.isDataInitialized();
×
32
    }
33

34
    @Override
35
    public void setDataNeedsUpdate() {
36
        super.setDataNeedsUpdate();
6✔
37
        dataNeedsUpdate = true;
9✔
38
    }
3✔
39

40
    @Override
41
    public void forceRefresh(long waitMillis) {
42
        super.forceRefresh(waitMillis);
×
43
        dataNeedsUpdate = true;
×
44
        dataInitialized = false;
×
45
    }
×
46

47
    private String label, rootNanopubId, type;
48
    private Nanopub rootNanopub = null;
9✔
49
    private SpaceData data = new SpaceData();
15✔
50

51
    private static class SpaceData implements Serializable {
6✔
52

53
        List<String> altIds = new ArrayList<>();
15✔
54

55
        String description = null;
9✔
56
        Calendar startDate, endDate;
57
        IRI defaultProvenance = null;
9✔
58

59
        List<IRI> admins = new ArrayList<>();
15✔
60
        Map<IRI, Set<SpaceMemberRoleRef>> users = new HashMap<>();
15✔
61
        List<SpaceMemberRoleRef> roles = new ArrayList<>();
15✔
62
        Map<IRI, SpaceMemberRole> roleMap = new HashMap<>();
15✔
63

64
        Map<String, IRI> adminPubkeyMap = new HashMap<>();
18✔
65

66
        void addAdmin(IRI admin, String npId) {
67
            // TODO This isn't efficient for long owner lists:
68
            if (admins.contains(admin)) return;
15!
69
            admins.add(admin);
15✔
70
            UserData ud = User.getUserData();
6✔
71
            // TODO Add approval measures for admin pubkeys in the future
72
            for (String pubkeyHash : ud.getPubkeyHashes(admin, null)) {
39✔
73
                adminPubkeyMap.put(pubkeyHash, admin);
18✔
74
            }
3✔
75
            users.computeIfAbsent(admin, (k) -> new HashSet<>()).add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, npId));
51✔
76
        }
3✔
77

78
    }
79

80
    private static final Map<String, String> TYPE_EMOJIS = Map.ofEntries(
27✔
81
            Map.entry("Alliance", "\uD83E\uDD1D"),
18✔
82
            Map.entry("Consortium", "\u2602\uFE0F"),
18✔
83
            Map.entry("Organization", "\uD83C\uDFE2"),
18✔
84
            Map.entry("Taskforce", "\uD83C\uDFAF"),
18✔
85
            Map.entry("Division", "\uD83C\uDFD7\uFE0F"),
18✔
86
            Map.entry("Taskunit", "\u2699\uFE0F"),
18✔
87
            Map.entry("Group", "\uD83D\uDC65"),
18✔
88
            Map.entry("Project", "\uD83D\uDE80"),
18✔
89
            Map.entry("Program", "\uD83D\uDCCB"),
18✔
90
            Map.entry("Initiative", "\uD83D\uDCA1"),
18✔
91
            Map.entry("Outlet", "\uD83D\uDCF0"),
18✔
92
            Map.entry("Campaign", "\uD83D\uDCE3"),
18✔
93
            Map.entry("Community", "\uD83C\uDF10"),
18✔
94
            Map.entry("Event", "\uD83C\uDFAA")
6✔
95
    );
96

97
    /**
98
     * Get the emoji associated with a space type name.
99
     *
100
     * @param typeName The short type name (e.g., "Alliance").
101
     * @return The emoji string, or an empty string if not found.
102
     */
103
    public static String getTypeEmoji(String typeName) {
104
        return TYPE_EMOJIS.getOrDefault(typeName, "");
×
105
    }
106

107
    private static String getCoreInfoString(ApiResponseEntry resp) {
108
        String id = resp.get("space");
×
109
        String rootNanopubId = resp.get("np");
×
110
        return id + " " + rootNanopubId;
×
111
    }
112

113
    private volatile boolean dataInitialized = false;
9✔
114
    private volatile boolean dataNeedsUpdate = true;
9✔
115
    private transient volatile Future<?> spaceDataFuture = null;
9✔
116

117
    Space(ApiResponseEntry resp) {
118
        super(resp.get("space"));
15✔
119
        initSpace(this);
9✔
120
        this.label = resp.get("label");
15✔
121
        this.type = resp.get("type");
15✔
122
        this.rootNanopubId = resp.get("np");
15✔
123
        this.rootNanopub = Utils.getAsNanopub(rootNanopubId);
15✔
124
        setCoreData(data);
12✔
125
    }
3✔
126

127
    void updateFromApi(ApiResponseEntry resp) {
128
        String newNpId = resp.get("np");
×
129
        if (!newNpId.equals(this.rootNanopubId)) {
×
130
            this.label = resp.get("label");
×
131
            this.type = resp.get("type");
×
132
            this.rootNanopubId = newNpId;
×
133
            this.rootNanopub = Utils.getAsNanopub(newNpId);
×
134
            setDataNeedsUpdate();
×
135
        }
136
    }
×
137

138
    /**
139
     * Get the root nanopublication ID of the space.
140
     *
141
     * @return The root nanopub ID.
142
     */
143
    @Override
144
    public String getNanopubId() {
145
        return rootNanopubId;
×
146
    }
147

148
    /**
149
     * Get a string combining the space ID and root nanopub ID for core identification.
150
     *
151
     * @return The core info string.
152
     */
153
    public String getCoreInfoString() {
154
        return getId() + " " + rootNanopubId;
×
155
    }
156

157
    /**
158
     * Get the root nanopublication of the space.
159
     *
160
     * @return The root Nanopub object.
161
     */
162
    @Override
163
    public Nanopub getNanopub() {
164
        return rootNanopub;
×
165
    }
166

167
    @Override
168
    public String getNamespace() {
169
        // FIXME this will be removed in the future
170
        return null;
×
171
    }
172

173
    /**
174
     * Get the label of the space.
175
     *
176
     * @return The space label.
177
     */
178
    @Override
179
    public String getLabel() {
180
        return label;
×
181
    }
182

183
    /**
184
     * Get the type of the space.
185
     *
186
     * @return The space type.
187
     */
188
    public String getType() {
189
        return type;
9✔
190
    }
191

192
    /**
193
     * Get the start date of the space.
194
     *
195
     * @return The start date as a Calendar object, or null if not set.
196
     */
197
    public Calendar getStartDate() {
198
        return data.startDate;
×
199
    }
200

201
    /**
202
     * Get the end date of the space.
203
     *
204
     * @return The end date as a Calendar object, or null if not set.
205
     */
206
    public Calendar getEndDate() {
207
        return data.endDate;
×
208
    }
209

210
    /**
211
     * Get a simplified label for the type of space by removing any namespace prefix.
212
     *
213
     * @return The simplified type label.
214
     */
215
    public String getTypeLabel() {
216
        return type.replaceFirst("^.*/", "");
×
217
    }
218

219
    /**
220
     * Get the description of the space.
221
     *
222
     * @return The description string.
223
     */
224
    public String getDescription() {
225
        return data.description;
×
226
    }
227

228

229
    /**
230
     * Get the list of admins in this space.
231
     *
232
     * @return List of admin IRIs.
233
     */
234
    public List<IRI> getAdmins() {
235
        ensureInitialized();
×
236
        return data.admins;
×
237
    }
238

239
    /**
240
     * Get the list of members in this space.
241
     *
242
     * @return List of member IRIs.
243
     */
244
    public List<IRI> getUsers() {
245
        ensureInitialized();
×
246
        List<IRI> users = new ArrayList<IRI>(data.users.keySet());
×
247
        users.sort(User.getUserData().userComparator);
×
248
        return users;
×
249
    }
250

251
    /**
252
     * Get the roles of a specific member in this space.
253
     *
254
     * @param userId The IRI of the member.
255
     * @return Set of roles assigned to the member, or null if the member is not part of this space.
256
     */
257
    public Set<SpaceMemberRoleRef> getMemberRoles(IRI userId) {
258
        ensureInitialized();
×
259
        return data.users.get(userId);
×
260
    }
261

262
    /**
263
     * Check if a user is a member of this space.
264
     *
265
     * @param userId The IRI of the user to check.
266
     * @return true if the user is a member, false otherwise.
267
     */
268
    public boolean isMember(IRI userId) {
269
        ensureInitialized();
×
270
        return data.users.containsKey(userId);
×
271
    }
272

273
    /**
274
     * Check if a public key is associated with an admin of this space.
275
     *
276
     * @param pubkey The public key hash to check.
277
     * @return true if the public key is associated with an admin, false otherwise.
278
     */
279
    public boolean isAdminPubkey(String pubkey) {
280
        ensureInitialized();
×
281
        return data.adminPubkeyMap.containsKey(pubkey);
×
282
    }
283

284
    @Override
285
    public boolean appliesTo(String elementId, Set<IRI> classes) {
286
        triggerSpaceDataUpdate();
×
287
        return super.appliesTo(elementId, classes);
×
288
    }
289

290
    /**
291
     * Get the default provenance IRI for this space.
292
     *
293
     * @return The default provenance IRI, or null if not set.
294
     */
295
    public IRI getDefaultProvenance() {
296
        return data.defaultProvenance;
×
297
    }
298

299
    /**
300
     * Get the roles defined in this space.
301
     *
302
     * @return List of roles.
303
     */
304
    public List<SpaceMemberRoleRef> getRoles() {
305
        return data.roles;
×
306
    }
307

308
    /**
309
     * Get the super ID of the space.
310
     *
311
     * @return Always returns null. Use getIdSuperspace() instead.
312
     */
313
    public String getSuperId() {
314
        return null;
×
315
    }
316

317
    /**
318
     * Get alternative IDs for the space.
319
     *
320
     * @return List of alternative IDs.
321
     */
322
    public List<String> getAltIDs() {
323
        return data.altIds;
12✔
324
    }
325

326
    private synchronized void ensureInitialized() {
327
        triggerSpaceDataUpdate();
×
328
        if (!dataInitialized && spaceDataFuture != null) {
×
329
            try {
330
                spaceDataFuture.get(30, TimeUnit.SECONDS);
×
331
            } catch (Exception ex) {
×
332
                logger.error("failed to await space data update", ex);
×
333
            }
×
334
        }
335
        Future<?> future = super.triggerDataUpdate();
×
336
        if (!dataInitialized && future != null) {
×
337
            try {
338
                future.get(30, TimeUnit.SECONDS);
×
339
            } catch (Exception ex) {
×
340
                logger.error("failed to await data update", ex);
×
341
            }
×
342
        }
343
    }
×
344

345
    @Override
346
    public synchronized Future<?> triggerDataUpdate() {
347
        triggerSpaceDataUpdate();
×
348
        return super.triggerDataUpdate();
×
349
    }
350

351
    private synchronized Future<?> triggerSpaceDataUpdate() {
352
        if (dataNeedsUpdate) {
×
353
            logger.info("Data needs update for space {} core data, starting update thread", getId());
×
354
            dataNeedsUpdate = false;
×
355
            spaceDataFuture = NanodashThreadPool.submit(() -> {
×
356
                try {
357
                    if (getRunUpdateAfter() != null) {
×
358
                        while (System.currentTimeMillis() < getRunUpdateAfter()) {
×
359
                            Thread.sleep(100);
×
360
                        }
361
                    }
362
                    SpaceData newData = new SpaceData();
×
363
                    setCoreData(newData);
×
364

365
                    newData.roles.add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, null));
×
366
                    newData.roleMap.put(KPXL_TERMS.HAS_ADMIN_PREDICATE, SpaceMemberRole.ADMIN_ROLE);
×
367

368
                    List<String> spaceIris = new ArrayList<>();
×
369
                    spaceIris.add(getId());
×
370
                    spaceIris.addAll(newData.altIds);
×
371

372
                    loadAdminsFromSpacesRepo(newData, spaceIris);
×
373
                    newData.admins.sort(User.getUserData().userComparator);
×
374

375
                    loadRolesFromSpacesRepo(newData, spaceIris);
×
376
                    loadMembersFromSpacesRepo(newData, spaceIris);
×
377

378
                    data = newData;
×
379
                    dataInitialized = true;
×
380
                } catch (Exception ex) {
×
381
                    logger.error("Error while trying to update space data: {}", ex.getMessage());
×
382
                    dataNeedsUpdate = true;
×
383
                }
×
384
            });
×
385
            return spaceDataFuture;
×
386
        }
387
        return spaceDataFuture;
×
388
    }
389

390
    private static String spaceValuesClause(List<String> spaceIris) {
391
        StringBuilder sb = new StringBuilder("  VALUES ?space { ");
×
392
        for (String iri : spaceIris) sb.append('<').append(iri).append("> ");
×
393
        sb.append("}\n");
×
394
        return sb.toString();
×
395
    }
396

397
    private static void loadAdminsFromSpacesRepo(SpaceData data, List<String> spaceIris) {
398
        String sparql = SpacesRepoAccess.PREFIXES
×
399
                + "SELECT DISTINCT ?agent ?np WHERE {\n"
400
                + SpacesRepoAccess.CURRENT_STATE_POINTER
401
                + spaceValuesClause(spaceIris)
×
402
                + "  GRAPH ?g {\n"
403
                + "    ?ri a gen:RoleInstantiation ;\n"
404
                + "        npa:inverseProperty gen:hasAdmin ;\n"
405
                + "        npa:forSpace ?space ;\n"
406
                + "        npa:forAgent ?agent ;\n"
407
                + "        npa:viaNanopub ?np .\n"
408
                + "  }\n"
409
                + "}";
410
        SpacesRepoAccess.get().select(sparql, null, b -> {
×
411
            IRI adminId = Utils.vf.createIRI(b.getValue("agent").stringValue());
×
412
            String np = b.getValue("np").stringValue();
×
413
            if (!data.admins.contains(adminId)) data.addAdmin(adminId, np);
×
414
            return null;
×
415
        });
416
    }
×
417

418
    private static void loadRolesFromSpacesRepo(SpaceData data, List<String> spaceIris) {
419
        String sparql = SpacesRepoAccess.PREFIXES
×
420
                + "SELECT ?role ?roleLabel ?roleName ?roleTitle ?roleAssignmentTemplate\n"
421
                + "       (GROUP_CONCAT(DISTINCT ?reg; separator=\" \") AS ?regularProperties)\n"
422
                + "       (GROUP_CONCAT(DISTINCT ?inv; separator=\" \") AS ?inverseProperties)\n"
423
                + "       ?ra_np WHERE {\n"
424
                + SpacesRepoAccess.CURRENT_STATE_POINTER
425
                + spaceValuesClause(spaceIris)
×
426
                + "  GRAPH ?g {\n"
427
                + "    ?ra a gen:RoleAssignment ;\n"
428
                + "        npa:forSpace ?space ;\n"
429
                + "        gen:hasRole ?role ;\n"
430
                + "        npa:viaNanopub ?ra_np .\n"
431
                + "  }\n"
432
                + "  GRAPH npa:spacesGraph {\n"
433
                + "    ?roleDecl a npa:RoleDeclaration ;\n"
434
                + "              npa:role ?role ;\n"
435
                + "              npa:viaNanopub ?role_np .\n"
436
                + "    OPTIONAL { ?roleDecl gen:hasRegularProperty ?reg }\n"
437
                + "    OPTIONAL { ?roleDecl gen:hasInverseProperty ?inv }\n"
438
                + "  }\n"
439
                + "  GRAPH npa:graph { ?role_np np:hasAssertion ?role_a . }\n"
440
                // Each OPTIONAL wraps its own GRAPH so a role with none of these
441
                // properties still produces a row (GRAPH ?x { OPTIONAL { ... } }
442
                // returns no solutions in RDF4J when the inner pattern is unmatched).
443
                + "  OPTIONAL { GRAPH ?role_a { ?role rdfs:label ?roleLabel } }\n"
444
                + "  OPTIONAL { GRAPH ?role_a { ?role dct:title ?roleTitle } }\n"
445
                + "  OPTIONAL { GRAPH ?role_a { ?role schema:name ?roleName } }\n"
446
                + "  OPTIONAL { GRAPH ?role_a { ?role gen:hasRoleAssignmentTemplate ?roleAssignmentTemplate } }\n"
447
                + "}\n"
448
                + "GROUP BY ?role ?roleLabel ?roleName ?roleTitle ?roleAssignmentTemplate ?ra_np";
449
        SpacesRepoAccess.get().select(sparql, null, b -> {
×
450
            ApiResponseEntry entry = new ApiResponseEntry();
×
451
            for (String k : List.of("role", "roleLabel", "roleName", "roleTitle",
×
452
                    "roleAssignmentTemplate", "regularProperties", "inverseProperties")) {
453
                if (b.getValue(k) != null) entry.add(k, b.getValue(k).stringValue());
×
454
            }
×
455
            SpaceMemberRole role = new SpaceMemberRole(entry);
×
456
            String raNp = b.getValue("ra_np") == null ? null : b.getValue("ra_np").stringValue();
×
457
            data.roles.add(new SpaceMemberRoleRef(role, raNp));
×
458
            for (IRI p : role.getRegularProperties()) data.roleMap.put(p, role);
×
459
            for (IRI p : role.getInverseProperties()) data.roleMap.put(p, role);
×
460
            return null;
×
461
        });
462
    }
×
463

464
    private static void loadMembersFromSpacesRepo(SpaceData data, List<String> spaceIris) {
465
        // Pulls RI candidates from the extraction graph (npa:spacesGraph) rather
466
        // than the validated state graph. The spaces-repo materialiser's
467
        // per-tier admit query can time out on some spaces (a planner blow-up
468
        // in RDF4J, observed on fdo-connect), leaving member RIs extracted but
469
        // never validated. To keep parity with the pre-migration behaviour
470
        // (legacy GET_SPACE_MEMBERS gated rows client-side by admin pubkey),
471
        // we read the universe of candidates here and apply the same client-side
472
        // admin-pubkey gate via data.adminPubkeyMap. Invalidated rows are
473
        // filtered out via the npx:invalidates triple in npa:graph.
474
        String sparql = SpacesRepoAccess.PREFIXES
×
475
                + "SELECT ?member ?np ?pkh ?regProp ?invProp WHERE {\n"
476
                + spaceValuesClause(spaceIris)
×
477
                + "  GRAPH npa:spacesGraph {\n"
478
                + "    ?ri a gen:RoleInstantiation ;\n"
479
                + "        npa:forSpace ?space ;\n"
480
                + "        npa:forAgent ?member ;\n"
481
                + "        npa:viaNanopub ?np ;\n"
482
                + "        npa:pubkeyHash ?pkh .\n"
483
                + "    OPTIONAL { ?ri npa:regularProperty ?regProp }\n"
484
                + "    OPTIONAL { ?ri npa:inverseProperty ?invProp }\n"
485
                + "    FILTER NOT EXISTS { ?ri npa:inverseProperty gen:hasAdmin }\n"
486
                + "  }\n"
487
                + "  FILTER NOT EXISTS { GRAPH npa:graph { ?invNp npx:invalidates ?np . } }\n"
488
                + "}";
489
        SpacesRepoAccess.get().select(sparql, null, b -> {
×
490
            String pkh = b.getValue("pkh") == null ? null : b.getValue("pkh").stringValue();
×
491
            if (pkh == null || !data.adminPubkeyMap.containsKey(pkh)) return null;
×
492
            IRI memberId = Utils.vf.createIRI(b.getValue("member").stringValue());
×
493
            String np = b.getValue("np").stringValue();
×
494
            SpaceMemberRole role = null;
×
495
            for (String key : new String[] {"regProp", "invProp"}) {
×
496
                if (b.getValue(key) != null) {
×
497
                    IRI pred = Utils.vf.createIRI(b.getValue(key).stringValue());
×
498
                    SpaceMemberRole candidate = data.roleMap.get(pred);
×
499
                    if (candidate != null) { role = candidate; break; }
×
500
                }
501
            }
502
            data.users.computeIfAbsent(memberId, k -> new HashSet<>())
×
503
                    .add(new SpaceMemberRoleRef(role, np));
×
504
            return null;
×
505
        });
506
    }
×
507

508
    private void setCoreData(SpaceData data) {
509
        for (Statement st : rootNanopub.getAssertion()) {
36✔
510
            if (st.getSubject().stringValue().equals(getId())) {
21!
511
                if (st.getPredicate().equals(OWL.SAMEAS) && st.getObject() instanceof IRI objIri) {
42!
512
                    data.altIds.add(objIri.stringValue());
21✔
513
                } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
15✔
514
                    data.description = st.getObject().stringValue();
18✔
515
                } else if (st.getPredicate().stringValue().equals("http://schema.org/startDate")) {
18✔
516
                    try {
517
                        data.startDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
518
                    } catch (IllegalArgumentException ex) {
×
519
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
520
                    }
3✔
521
                } else if (st.getPredicate().stringValue().equals("http://schema.org/endDate")) {
18✔
522
                    try {
523
                        data.endDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
524
                    } catch (IllegalArgumentException ex) {
×
525
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
526
                    }
3✔
527
                } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ADMIN) && st.getObject() instanceof IRI obj) {
42!
528
                    data.addAdmin(obj, rootNanopub.getUri().stringValue());
24✔
529
                } else if (st.getPredicate().equals(NTEMPLATE.HAS_DEFAULT_PROVENANCE) && st.getObject() instanceof IRI obj) {
15!
530
                    data.defaultProvenance = obj;
×
531
                }
532
            }
533
        }
3✔
534
    }
3✔
535

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

© 2026 Coveralls, Inc