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

knowledgepixels / nanodash / 26455055595

26 May 2026 02:38PM UTC coverage: 20.427% (-0.3%) from 20.748%
26455055595

Pull #468

github

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

1005 of 6260 branches covered (16.05%)

Branch coverage included in aggregate %.

2600 of 11388 relevant lines covered (22.83%)

3.27 hits per line

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

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

3
import com.google.common.collect.ArrayListMultimap;
4
import com.google.common.collect.Multimap;
5
import com.knowledgepixels.nanodash.*;
6
import com.knowledgepixels.nanodash.vocabulary.KPXL_TERMS;
7
import jakarta.xml.bind.DatatypeConverter;
8
import org.eclipse.rdf4j.model.IRI;
9
import org.eclipse.rdf4j.model.Statement;
10
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
11
import org.eclipse.rdf4j.model.vocabulary.OWL;
12
import org.nanopub.Nanopub;
13
import org.nanopub.extra.services.ApiResponse;
14
import org.nanopub.extra.services.ApiResponseEntry;
15
import org.nanopub.extra.services.QueryRef;
16
import org.nanopub.vocabulary.NTEMPLATE;
17
import org.slf4j.Logger;
18
import org.slf4j.LoggerFactory;
19

20
import java.io.Serializable;
21
import java.util.*;
22
import java.util.concurrent.Future;
23
import java.util.concurrent.TimeUnit;
24

25
/**
26
 * Class representing a "Space", which can be any kind of collaborative unit, like a project, group, or event.
27
 */
28
public class Space extends AbstractResourceWithProfile {
29

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

32
    @Override
33
    public boolean isDataInitialized() {
34
        triggerDataUpdate();
×
35
        return dataInitialized && super.isDataInitialized();
×
36
    }
37

38
    @Override
39
    public void setDataNeedsUpdate() {
40
        super.setDataNeedsUpdate();
6✔
41
        dataNeedsUpdate = true;
9✔
42
    }
3✔
43

44
    @Override
45
    public void forceRefresh(long waitMillis) {
46
        super.forceRefresh(waitMillis);
×
47
        dataNeedsUpdate = true;
×
48
        dataInitialized = false;
×
49
    }
×
50

51
    private String label, rootNanopubId, type;
52
    private Nanopub rootNanopub = null;
9✔
53
    private SpaceData data = new SpaceData();
15✔
54

55
    private static class SpaceData implements Serializable {
6✔
56

57
        List<String> altIds = new ArrayList<>();
15✔
58

59
        String description = null;
9✔
60
        Calendar startDate, endDate;
61
        IRI defaultProvenance = null;
9✔
62

63
        List<IRI> admins = new ArrayList<>();
15✔
64
        Map<IRI, Set<SpaceMemberRoleRef>> users = new HashMap<>();
15✔
65
        List<SpaceMemberRoleRef> roles = new ArrayList<>();
15✔
66
        Map<IRI, SpaceMemberRole> roleMap = new HashMap<>();
15✔
67

68
        Map<String, IRI> adminPubkeyMap = new HashMap<>();
15✔
69
        Map<String, IRI> userPubkeyMap = new HashMap<>();
18✔
70

71
        void addAdmin(IRI admin, String npId) {
72
            // TODO This isn't efficient for long owner lists:
73
            if (admins.contains(admin)) return;
15!
74
            admins.add(admin);
15✔
75
            // adminPubkeyMap is populated in bulk by loadAdminPubkeyHashesFromSpacesRepo,
76
            // sourcing trust-state-validated (agent, pubkey-hash) pairs from the
77
            // spaces repo's npa:AccountState rows.
78
            users.computeIfAbsent(admin, (k) -> new HashSet<>()).add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, npId));
51✔
79
        }
3✔
80

81
    }
82

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

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

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

116
    private volatile boolean dataInitialized = false;
9✔
117
    private volatile boolean dataNeedsUpdate = true;
9✔
118
    private transient volatile Future<?> spaceDataFuture = null;
9✔
119

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

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

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

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

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

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

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

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

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

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

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

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

231

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

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

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

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

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

287
    /**
288
     * Get the trust-state-validated pubkey hashes of this space's admins.
289
     *
290
     * @return Set of admin pubkey hashes, sourced from the spaces repo.
291
     */
292
    public Set<String> getAdminPubkeyHashes() {
293
        ensureInitialized();
×
294
        return data.adminPubkeyMap.keySet();
×
295
    }
296

297
    /**
298
     * Get the trust-state-validated pubkey hashes of this space's users
299
     * (admins + members).
300
     *
301
     * @return Set of user pubkey hashes, sourced from the spaces repo.
302
     */
303
    public Set<String> getUserPubkeyHashes() {
304
        ensureInitialized();
×
305
        return data.userPubkeyMap.keySet();
×
306
    }
307

308
    @Override
309
    public boolean appliesTo(String elementId, Set<IRI> classes) {
310
        triggerSpaceDataUpdate();
×
311
        return super.appliesTo(elementId, classes);
×
312
    }
313

314
    /**
315
     * Get the default provenance IRI for this space.
316
     *
317
     * @return The default provenance IRI, or null if not set.
318
     */
319
    public IRI getDefaultProvenance() {
320
        return data.defaultProvenance;
×
321
    }
322

323
    /**
324
     * Get the roles defined in this space.
325
     *
326
     * @return List of roles.
327
     */
328
    public List<SpaceMemberRoleRef> getRoles() {
329
        return data.roles;
×
330
    }
331

332
    /**
333
     * Get the super ID of the space.
334
     *
335
     * @return Always returns null. Use getIdSuperspace() instead.
336
     */
337
    public String getSuperId() {
338
        return null;
×
339
    }
340

341
    /**
342
     * Get alternative IDs for the space.
343
     *
344
     * @return List of alternative IDs.
345
     */
346
    public List<String> getAltIDs() {
347
        return data.altIds;
12✔
348
    }
349

350
    private synchronized void ensureInitialized() {
351
        triggerSpaceDataUpdate();
×
352
        if (!dataInitialized && spaceDataFuture != null) {
×
353
            try {
354
                spaceDataFuture.get(30, TimeUnit.SECONDS);
×
355
            } catch (Exception ex) {
×
356
                logger.error("failed to await space data update", ex);
×
357
            }
×
358
        }
359
        Future<?> future = super.triggerDataUpdate();
×
360
        if (!dataInitialized && future != null) {
×
361
            try {
362
                future.get(30, TimeUnit.SECONDS);
×
363
            } catch (Exception ex) {
×
364
                logger.error("failed to await data update", ex);
×
365
            }
×
366
        }
367
    }
×
368

369
    @Override
370
    public synchronized Future<?> triggerDataUpdate() {
371
        triggerSpaceDataUpdate();
×
372
        return super.triggerDataUpdate();
×
373
    }
374

375
    private synchronized Future<?> triggerSpaceDataUpdate() {
376
        if (dataNeedsUpdate) {
×
377
            logger.info("Data needs update for space {} core data, starting update thread", getId());
×
378
            dataNeedsUpdate = false;
×
379
            spaceDataFuture = NanodashThreadPool.submit(() -> {
×
380
                try {
381
                    if (getRunUpdateAfter() != null) {
×
382
                        while (System.currentTimeMillis() < getRunUpdateAfter()) {
×
383
                            Thread.sleep(100);
×
384
                        }
385
                    }
386
                    SpaceData newData = new SpaceData();
×
387
                    setCoreData(newData);
×
388

389
                    newData.roles.add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, null));
×
390
                    newData.roleMap.put(KPXL_TERMS.HAS_ADMIN_PREDICATE, SpaceMemberRole.ADMIN_ROLE);
×
391

392
                    List<String> spaceIris = new ArrayList<>();
×
393
                    spaceIris.add(getId());
×
394
                    spaceIris.addAll(newData.altIds);
×
395

396
                    loadAdminsFromSpacesRepo(newData, spaceIris);
×
397
                    newData.admins.sort(User.getUserData().userComparator);
×
398
                    loadAdminPubkeyHashesFromSpacesRepo(newData, spaceIris);
×
399

400
                    loadRolesFromSpacesRepo(newData, spaceIris);
×
401
                    loadMembersFromSpacesRepo(newData, spaceIris);
×
402
                    loadUserPubkeyHashesFromSpacesRepo(newData);
×
403

404
                    data = newData;
×
405
                    dataInitialized = true;
×
406
                } catch (Exception ex) {
×
407
                    logger.error("Error while trying to update space data: {}", ex.getMessage());
×
408
                    dataNeedsUpdate = true;
×
409
                }
×
410
            });
×
411
            return spaceDataFuture;
×
412
        }
413
        return spaceDataFuture;
×
414
    }
415

416
    private static Multimap<String, String> spaceParams(List<String> spaceIris) {
417
        Multimap<String, String> params = ArrayListMultimap.create();
×
418
        for (String iri : spaceIris) params.put("space", iri);
×
419
        return params;
×
420
    }
421

422
    private static List<ApiResponseEntry> runSpacesQuery(String queryId, Multimap<String, String> params) {
423
        // Like the prior direct-SPARQL path, a failed/empty query yields an empty
424
        // list rather than aborting the rest of the space-data load.
425
        ApiResponse resp = ApiCache.retrieveResponseSync(new QueryRef(queryId, params), true);
×
426
        return resp == null ? List.of() : resp.getData();
×
427
    }
428

429
    private static void loadAdminsFromSpacesRepo(SpaceData data, List<String> spaceIris) {
430
        for (ApiResponseEntry r : runSpacesQuery(QueryApiAccess.GET_SPACE_ADMINS, spaceParams(spaceIris))) {
×
431
            IRI adminId = Utils.vf.createIRI(r.get("agent"));
×
432
            String np = r.get("np");
×
433
            if (!data.admins.contains(adminId)) data.addAdmin(adminId, np);
×
434
        }
×
435
    }
×
436

437
    private static void loadAdminPubkeyHashesFromSpacesRepo(SpaceData data, List<String> spaceIris) {
438
        // TODO Push the view-display admin gate server-side (a published
439
        // get-view-displays variant that joins through the spaces-repo admins) so
440
        // the adminPubkeyMap dependency disappears entirely — see callers of
441
        // Space.isAdminPubkey in AbstractResourceWithProfile and DownloadRdfPage.
442
        //
443
        // Source: trust-state-validated (agent, pubkey-hash) pairs for the
444
        // space's admin RIs. Stricter than the prior UserData.getPubkeyHashes
445
        // source (only pubkeys that are in the current trust state count).
446
        for (ApiResponseEntry r : runSpacesQuery(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES, spaceParams(spaceIris))) {
×
447
            data.adminPubkeyMap.put(r.get("pkh"), Utils.vf.createIRI(r.get("agent")));
×
448
        }
×
449
    }
×
450

451
    private static void loadRolesFromSpacesRepo(SpaceData data, List<String> spaceIris) {
452
        for (ApiResponseEntry r : runSpacesQuery(QueryApiAccess.GET_SPACE_ROLES, spaceParams(spaceIris))) {
×
453
            ApiResponseEntry entry = new ApiResponseEntry();
×
454
            for (String k : List.of("role", "roleLabel", "roleName", "roleTitle",
×
455
                    "roleAssignmentTemplate", "regularProperties", "inverseProperties")) {
456
                String v = r.get(k);
×
457
                if (v != null && !v.isEmpty()) entry.add(k, v);
×
458
            }
×
459
            SpaceMemberRole role = new SpaceMemberRole(entry);
×
460
            String raNp = r.get("ra_np");
×
461
            if (raNp != null && raNp.isEmpty()) raNp = null;
×
462
            data.roles.add(new SpaceMemberRoleRef(role, raNp));
×
463
            for (IRI p : role.getRegularProperties()) data.roleMap.put(p, role);
×
464
            for (IRI p : role.getInverseProperties()) data.roleMap.put(p, role);
×
465
        }
×
466
    }
×
467

468
    private static void loadMembersFromSpacesRepo(SpaceData data, List<String> spaceIris) {
469
        // Pulls RI candidates from the extraction graph (npa:spacesGraph) rather
470
        // than the validated state graph. The materialiser is correctly stricter
471
        // (e.g., it stops admitting RIs when their role declaration is
472
        // invalidated, even if the role assignment still points at the old IRI;
473
        // and the design admits non-admin-published observer-tier RIs only via
474
        // AccountState self-evidence, dropping any whose agents aren't in the
475
        // trust state). Nanodash matches the looser pre-migration semantic of
476
        // get-space-members: any RI whose predicate corresponds to a role
477
        // attached to the space (the admin-gating happens server-side on the
478
        // role attachment, not on the per-member nanopub) is admitted,
479
        // regardless of who signed the member RI. Invalidated rows are filtered
480
        // server-side via the npx:invalidates triple in npa:graph.
481
        for (ApiResponseEntry r : runSpacesQuery(QueryApiAccess.GET_SPACE_MEMBERS, spaceParams(spaceIris))) {
×
482
            SpaceMemberRole role = null;
×
483
            for (String key : new String[] {"regProp", "invProp"}) {
×
484
                String val = r.get(key);
×
485
                if (val != null && !val.isEmpty()) {
×
486
                    IRI pred = Utils.vf.createIRI(val);
×
487
                    SpaceMemberRole candidate = data.roleMap.get(pred);
×
488
                    if (candidate != null) { role = candidate; break; }
×
489
                }
490
            }
491
            // Gate by role-predicate match: only admit members whose predicate
492
            // corresponds to a role that was attached to this space by an admin
493
            // (data.roleMap is populated from validated gen:RoleAssignment rows
494
            // in loadRolesFromSpacesRepo).
495
            if (role == null) continue;
×
496
            IRI memberId = Utils.vf.createIRI(r.get("member"));
×
497
            String np = r.get("np");
×
498
            data.users.computeIfAbsent(memberId, k -> new HashSet<>())
×
499
                    .add(new SpaceMemberRoleRef(role, np));
×
500
        }
×
501
    }
×
502

503
    private static void loadUserPubkeyHashesFromSpacesRepo(SpaceData data) {
504
        // TODO Push the per-view pubkey filtering server-side too, so view queries
505
        // gate by user-of-space without nanodash having to expand the placeholder
506
        // client-side at all — see callers of Space.getUserPubkeyHashes /
507
        // getAdminPubkeyHashes in ViewList and DownloadRdfPage.
508
        //
509
        // Source: trust-state-validated (agent, pubkey-hash) pairs from
510
        // npa:AccountState for every user agent (admin or admin-gated member)
511
        // already loaded into data.users. Stricter than the prior
512
        // UserData.getPubkeyHashes source — pubkey hashes of users not in
513
        // the current trust state are not returned.
514
        if (data.users.isEmpty()) return;
×
515
        Multimap<String, String> params = ArrayListMultimap.create();
×
516
        for (IRI agent : data.users.keySet()) {
×
517
            params.put("agent", agent.stringValue());
×
518
        }
×
519
        for (ApiResponseEntry r : runSpacesQuery(QueryApiAccess.GET_AGENT_PUBKEY_HASHES, params)) {
×
520
            data.userPubkeyMap.put(r.get("pkh"), Utils.vf.createIRI(r.get("agent")));
×
521
        }
×
522
    }
×
523

524
    private void setCoreData(SpaceData data) {
525
        for (Statement st : rootNanopub.getAssertion()) {
36✔
526
            if (st.getSubject().stringValue().equals(getId())) {
21!
527
                if (st.getPredicate().equals(OWL.SAMEAS) && st.getObject() instanceof IRI objIri) {
42!
528
                    data.altIds.add(objIri.stringValue());
21✔
529
                } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
15✔
530
                    data.description = st.getObject().stringValue();
18✔
531
                } else if (st.getPredicate().stringValue().equals("http://schema.org/startDate")) {
18✔
532
                    try {
533
                        data.startDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
534
                    } catch (IllegalArgumentException ex) {
×
535
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
536
                    }
3✔
537
                } else if (st.getPredicate().stringValue().equals("http://schema.org/endDate")) {
18✔
538
                    try {
539
                        data.endDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
540
                    } catch (IllegalArgumentException ex) {
×
541
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
542
                    }
3✔
543
                } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ADMIN) && st.getObject() instanceof IRI obj) {
42!
544
                    data.addAdmin(obj, rootNanopub.getUri().stringValue());
24✔
545
                } else if (st.getPredicate().equals(NTEMPLATE.HAS_DEFAULT_PROVENANCE) && st.getObject() instanceof IRI obj) {
15!
546
                    data.defaultProvenance = obj;
×
547
                }
548
            }
549
        }
3✔
550
    }
3✔
551

552
}
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