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

knowledgepixels / nanodash / 27145358627

08 Jun 2026 02:39PM UTC coverage: 20.682% (-0.3%) from 20.947%
27145358627

push

github

web-flow
Merge pull request #479 from knowledgepixels/feat/about-pages-478

Resource-page tabs, presets, and role-gated view actions (#478, #302)

1052 of 6429 branches covered (16.36%)

Branch coverage included in aggregate %.

2642 of 11432 relevant lines covered (23.11%)

3.31 hits per line

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

25.0
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

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

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

30
    private String label, rootNanopubId, type;
31
    private Nanopub rootNanopub = null;
9✔
32

33
    // Core data — derived directly from the root nanopub assertion.
34
    private final List<String> altIds = new ArrayList<>();
15✔
35
    private final List<IRI> rootAdmins = new ArrayList<>();
15✔
36
    private String description = null;
9✔
37
    private Calendar startDate, endDate;
38
    private IRI defaultProvenance = null;
9✔
39

40
    // Derived data — admins / members / roles loaded from the spaces repo and
41
    // memoised by ApiResponse object identity (rebuild when ApiCache returns a
42
    // fresh response for any of the three underlying queries).
43
    private volatile ApiResponse cachedAdminsResp, cachedRolesResp, cachedMembersResp;
44
    private volatile SpaceData cachedData = SpaceData.EMPTY;
9✔
45

46
    private static class SpaceData implements Serializable {
47

48
        final List<IRI> admins;
49
        final Map<IRI, Set<SpaceMemberRoleRef>> users;
50
        final List<SpaceMemberRoleRef> roles;
51
        final Map<IRI, SpaceMemberRole> roleMap;
52

53
        SpaceData(List<IRI> admins, Map<IRI, Set<SpaceMemberRoleRef>> users,
54
                  List<SpaceMemberRoleRef> roles, Map<IRI, SpaceMemberRole> roleMap) {
6✔
55
            this.admins = admins;
9✔
56
            this.users = users;
9✔
57
            this.roles = roles;
9✔
58
            this.roleMap = roleMap;
9✔
59
        }
3✔
60

61
        static final SpaceData EMPTY = new SpaceData(
9✔
62
                Collections.emptyList(), Collections.emptyMap(),
6✔
63
                Collections.emptyList(), Collections.emptyMap());
12✔
64
    }
65

66
    private static final Map<String, String> TYPE_EMOJIS = Map.ofEntries(
27✔
67
            Map.entry("Alliance", "🤝"),
18✔
68
            Map.entry("Consortium", "☂️"),
18✔
69
            Map.entry("Organization", "🏢"),
18✔
70
            Map.entry("Taskforce", "🎯"),
18✔
71
            Map.entry("Division", "🏗️"),
18✔
72
            Map.entry("Taskunit", "⚙️"),
18✔
73
            Map.entry("Group", "👥"),
18✔
74
            Map.entry("Project", "🚀"),
18✔
75
            Map.entry("Program", "📋"),
18✔
76
            Map.entry("Initiative", "💡"),
18✔
77
            Map.entry("Outlet", "📰"),
18✔
78
            Map.entry("Campaign", "📣"),
18✔
79
            Map.entry("Community", "🌐"),
18✔
80
            Map.entry("Event", "🎪")
6✔
81
    );
82

83
    /**
84
     * Get the emoji associated with a space type name.
85
     *
86
     * @param typeName The short type name (e.g., "Alliance").
87
     * @return The emoji string, or an empty string if not found.
88
     */
89
    public static String getTypeEmoji(String typeName) {
90
        return TYPE_EMOJIS.getOrDefault(typeName, "");
×
91
    }
92

93
    Space(ApiResponseEntry resp) {
94
        super(resp.get("space"));
15✔
95
        initSpace(this);
9✔
96
        this.label = resp.get("label");
15✔
97
        this.type = resp.get("type");
15✔
98
        this.rootNanopubId = resp.get("np");
15✔
99
        this.rootNanopub = Utils.getAsNanopub(rootNanopubId);
15✔
100
        readCoreData();
6✔
101
    }
3✔
102

103
    void updateFromApi(ApiResponseEntry resp) {
104
        String newNpId = resp.get("np");
×
105
        if (!newNpId.equals(this.rootNanopubId)) {
×
106
            this.label = resp.get("label");
×
107
            this.type = resp.get("type");
×
108
            this.rootNanopubId = newNpId;
×
109
            this.rootNanopub = Utils.getAsNanopub(newNpId);
×
110
            altIds.clear();
×
111
            rootAdmins.clear();
×
112
            description = null;
×
113
            startDate = null;
×
114
            endDate = null;
×
115
            defaultProvenance = null;
×
116
            readCoreData();
×
117
            setDataNeedsUpdate();
×
118
        }
119
    }
×
120

121
    /**
122
     * Get the root nanopublication ID of the space.
123
     *
124
     * @return The root nanopub ID.
125
     */
126
    @Override
127
    public String getNanopubId() {
128
        return rootNanopubId;
×
129
    }
130

131
    /**
132
     * Get a string combining the space ID and root nanopub ID for core identification.
133
     *
134
     * @return The core info string.
135
     */
136
    public String getCoreInfoString() {
137
        return getId() + " " + rootNanopubId;
×
138
    }
139

140
    /**
141
     * Get the root nanopublication of the space.
142
     *
143
     * @return The root Nanopub object.
144
     */
145
    @Override
146
    public Nanopub getNanopub() {
147
        return rootNanopub;
×
148
    }
149

150
    @Override
151
    protected Set<IRI> getOwnClasses() {
152
        return Set.of(KPXL_TERMS.SPACE);
×
153
    }
154

155
    @Override
156
    public String getNamespace() {
157
        // FIXME this will be removed in the future
158
        return null;
×
159
    }
160

161
    /**
162
     * Get the label of the space.
163
     *
164
     * @return The space label.
165
     */
166
    @Override
167
    public String getLabel() {
168
        return label;
×
169
    }
170

171
    /**
172
     * Get the type of the space.
173
     *
174
     * @return The space type.
175
     */
176
    public String getType() {
177
        return type;
×
178
    }
179

180
    /**
181
     * Get the start date of the space.
182
     *
183
     * @return The start date as a Calendar object, or null if not set.
184
     */
185
    public Calendar getStartDate() {
186
        return startDate;
×
187
    }
188

189
    /**
190
     * Get the end date of the space.
191
     *
192
     * @return The end date as a Calendar object, or null if not set.
193
     */
194
    public Calendar getEndDate() {
195
        return endDate;
×
196
    }
197

198
    /**
199
     * Get a simplified label for the type of space by removing any namespace prefix.
200
     *
201
     * @return The simplified type label.
202
     */
203
    public String getTypeLabel() {
204
        return type.replaceFirst("^.*/", "");
×
205
    }
206

207
    /**
208
     * Get the description of the space.
209
     *
210
     * @return The description string.
211
     */
212
    public String getDescription() {
213
        return description;
×
214
    }
215

216

217
    /**
218
     * Get the list of admins in this space.
219
     *
220
     * @return List of admin IRIs.
221
     */
222
    public List<IRI> getAdmins() {
223
        return currentData().admins;
×
224
    }
225

226
    /**
227
     * Get the list of members in this space.
228
     *
229
     * @return List of member IRIs.
230
     */
231
    public List<IRI> getUsers() {
232
        List<IRI> users = new ArrayList<>(currentData().users.keySet());
×
233
        users.sort(User.getUserData().userComparator);
×
234
        return users;
×
235
    }
236

237
    /**
238
     * Get the roles of a specific member in this space.
239
     *
240
     * @param userId The IRI of the member.
241
     * @return Set of roles assigned to the member, or null if the member is not part of this space.
242
     */
243
    public Set<SpaceMemberRoleRef> getMemberRoles(IRI userId) {
244
        return currentData().users.get(userId);
×
245
    }
246

247
    /**
248
     * Check if a user is a member of this space.
249
     *
250
     * @param userId The IRI of the user to check.
251
     * @return true if the user is a member, false otherwise.
252
     */
253
    public boolean isMember(IRI userId) {
254
        return currentData().users.containsKey(userId);
×
255
    }
256

257
    /**
258
     * Get the highest role tier the given user holds in this space, as a numeric
259
     * rank for threshold comparisons (admin {@literal >} maintainer {@literal >}
260
     * member {@literal >} observer). A user holding no role gets
261
     * {@link SpaceMemberRole#EVERYONE_RANK}.
262
     *
263
     * @param userId The IRI of the user.
264
     * @return the highest tier rank held, or the "everyone" floor if none.
265
     */
266
    public int userTier(IRI userId) {
267
        Set<SpaceMemberRoleRef> roles = getMemberRoles(userId);
×
268
        if (roles == null || roles.isEmpty()) return SpaceMemberRole.EVERYONE_RANK;
×
269
        int max = SpaceMemberRole.EVERYONE_RANK;
×
270
        for (SpaceMemberRoleRef ref : roles) {
×
271
            max = Math.max(max, ref.getRole().getTierRank());
×
272
        }
×
273
        return max;
×
274
    }
275

276
    /**
277
     * Check whether the given user holds the specific role (by role IRI) in this
278
     * space.
279
     *
280
     * @param userId  The IRI of the user.
281
     * @param roleIri The specific role IRI.
282
     * @return true if the user holds that exact role, false otherwise.
283
     */
284
    public boolean viewerHoldsRole(IRI userId, IRI roleIri) {
285
        Set<SpaceMemberRoleRef> roles = getMemberRoles(userId);
×
286
        if (roles == null) return false;
×
287
        for (SpaceMemberRoleRef ref : roles) {
×
288
            if (ref.getRole().getId().equals(roleIri)) return true;
×
289
        }
×
290
        return false;
×
291
    }
292

293
    /**
294
     * Check if a public key is associated with an admin of this space.
295
     *
296
     * @param pubkey The public key hash to check.
297
     * @return true if the public key is associated with an admin, false otherwise.
298
     */
299
    public boolean isAdminPubkey(String pubkey) {
300
        if (pubkey == null) return false;
×
301
        ApiResponse resp = ApiCache.retrieveResponseSync(
×
302
                new QueryRef(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES, spaceParams(allSpaceIris())), false);
×
303
        if (resp == null) return false;
×
304
        for (ApiResponseEntry r : resp.getData()) {
×
305
            if (pubkey.equals(r.get("pkh"))) return true;
×
306
        }
×
307
        return false;
×
308
    }
309

310
    /**
311
     * Get the default provenance IRI for this space.
312
     *
313
     * @return The default provenance IRI, or null if not set.
314
     */
315
    public IRI getDefaultProvenance() {
316
        return defaultProvenance;
×
317
    }
318

319
    /**
320
     * Get the roles defined in this space.
321
     *
322
     * @return List of roles.
323
     */
324
    public List<SpaceMemberRoleRef> getRoles() {
325
        return currentData().roles;
×
326
    }
327

328
    /**
329
     * Get the super ID of the space.
330
     *
331
     * @return Always returns null. Use getIdSuperspace() instead.
332
     */
333
    public String getSuperId() {
334
        return null;
×
335
    }
336

337
    /**
338
     * Get alternative IDs for the space.
339
     *
340
     * @return List of alternative IDs.
341
     */
342
    public List<String> getAltIDs() {
343
        return altIds;
9✔
344
    }
345

346
    @Override
347
    public void forceRefresh(long waitMillis) {
348
        super.forceRefresh(waitMillis);
×
349
        Multimap<String, String> params = spaceParams(allSpaceIris());
×
350
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_ADMINS, params), waitMillis);
×
351
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_ROLES, params), waitMillis);
×
352
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_MEMBERS, params), waitMillis);
×
353
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES, params), waitMillis);
×
354
    }
×
355

356
    private List<String> allSpaceIris() {
357
        List<String> iris = new ArrayList<>(altIds.size() + 1);
×
358
        iris.add(getId());
×
359
        iris.addAll(altIds);
×
360
        return iris;
×
361
    }
362

363
    private synchronized SpaceData currentData() {
364
        Multimap<String, String> params = spaceParams(allSpaceIris());
×
365
        ApiResponse adminsResp = ApiCache.retrieveResponseSync(
×
366
                new QueryRef(QueryApiAccess.GET_SPACE_ADMINS, params), false);
367
        ApiResponse rolesResp = ApiCache.retrieveResponseSync(
×
368
                new QueryRef(QueryApiAccess.GET_SPACE_ROLES, params), false);
369
        ApiResponse membersResp = ApiCache.retrieveResponseSync(
×
370
                new QueryRef(QueryApiAccess.GET_SPACE_MEMBERS, params), false);
371
        if (adminsResp == cachedAdminsResp && rolesResp == cachedRolesResp && membersResp == cachedMembersResp) {
×
372
            return cachedData;
×
373
        }
374
        SpaceData newData = buildData(adminsResp, rolesResp, membersResp);
×
375
        cachedAdminsResp = adminsResp;
×
376
        cachedRolesResp = rolesResp;
×
377
        cachedMembersResp = membersResp;
×
378
        cachedData = newData;
×
379
        return newData;
×
380
    }
381

382
    private SpaceData buildData(ApiResponse adminsResp, ApiResponse rolesResp, ApiResponse membersResp) {
383
        List<IRI> admins = new ArrayList<>();
×
384
        Map<IRI, Set<SpaceMemberRoleRef>> users = new HashMap<>();
×
385
        List<SpaceMemberRoleRef> roles = new ArrayList<>();
×
386
        Map<IRI, SpaceMemberRole> roleMap = new HashMap<>();
×
387

388
        // Seed from rootNanopub-derived state
389
        for (IRI rootAdmin : rootAdmins) {
×
390
            admins.add(rootAdmin);
×
391
            users.computeIfAbsent(rootAdmin, k -> new HashSet<>())
×
392
                    .add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, rootNanopubId));
×
393
        }
×
394
        roles.add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, null));
×
395
        roleMap.put(KPXL_TERMS.HAS_ADMIN_PREDICATE, SpaceMemberRole.ADMIN_ROLE);
×
396

397
        loadAdmins(admins, users, adminsResp);
×
398
        admins.sort(User.getUserData().userComparator);
×
399

400
        loadRoles(roles, roleMap, rolesResp);
×
401
        loadMembers(users, roleMap, membersResp);
×
402

403
        return new SpaceData(admins, users, roles, roleMap);
×
404
    }
405

406
    private static Multimap<String, String> spaceParams(List<String> spaceIris) {
407
        Multimap<String, String> params = ArrayListMultimap.create();
×
408
        for (String iri : spaceIris) params.put("space", iri);
×
409
        return params;
×
410
    }
411

412
    private static void loadAdmins(List<IRI> admins, Map<IRI, Set<SpaceMemberRoleRef>> users, ApiResponse resp) {
413
        if (resp == null) return;
×
414
        for (ApiResponseEntry r : resp.getData()) {
×
415
            IRI adminId = Utils.vf.createIRI(r.get("agent"));
×
416
            String np = r.get("np");
×
417
            if (admins.contains(adminId)) continue;
×
418
            admins.add(adminId);
×
419
            users.computeIfAbsent(adminId, k -> new HashSet<>())
×
420
                    .add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, np));
×
421
        }
×
422
    }
×
423

424
    private static void loadRoles(List<SpaceMemberRoleRef> roles, Map<IRI, SpaceMemberRole> roleMap, ApiResponse resp) {
425
        if (resp == null) return;
×
426
        for (ApiResponseEntry r : resp.getData()) {
×
427
            ApiResponseEntry entry = new ApiResponseEntry();
×
428
            for (String k : List.of("role", "roleLabel", "roleName", "roleTitle",
×
429
                    "roleAssignmentTemplate", "regularProperties", "inverseProperties", "roleType")) {
430
                String v = r.get(k);
×
431
                if (v != null && !v.isEmpty()) entry.add(k, v);
×
432
            }
×
433
            SpaceMemberRole role = new SpaceMemberRole(entry);
×
434
            String raNp = r.get("ra_np");
×
435
            if (raNp != null && raNp.isEmpty()) raNp = null;
×
436
            roles.add(new SpaceMemberRoleRef(role, raNp));
×
437
            for (IRI p : role.getRegularProperties()) roleMap.put(p, role);
×
438
            for (IRI p : role.getInverseProperties()) roleMap.put(p, role);
×
439
        }
×
440
    }
×
441

442
    private static void loadMembers(Map<IRI, Set<SpaceMemberRoleRef>> users, Map<IRI, SpaceMemberRole> roleMap, ApiResponse resp) {
443
        // Pulls RI candidates from the extraction graph (npa:spacesGraph) rather
444
        // than the validated state graph. The materialiser is correctly stricter
445
        // (e.g., it stops admitting RIs when their role declaration is
446
        // invalidated, even if the role assignment still points at the old IRI;
447
        // and the design admits non-admin-published observer-tier RIs only via
448
        // AccountState self-evidence, dropping any whose agents aren't in the
449
        // trust state). Nanodash matches the looser pre-migration semantic of
450
        // get-space-members: any RI whose predicate corresponds to a role
451
        // attached to the space (the admin-gating happens server-side on the
452
        // role attachment, not on the per-member nanopub) is admitted,
453
        // regardless of who signed the member RI. Invalidated rows are filtered
454
        // server-side via the npx:invalidates triple in npa:graph.
455
        if (resp == null) return;
×
456
        for (ApiResponseEntry r : resp.getData()) {
×
457
            SpaceMemberRole role = null;
×
458
            for (String key : new String[] {"regProp", "invProp"}) {
×
459
                String val = r.get(key);
×
460
                if (val != null && !val.isEmpty()) {
×
461
                    IRI pred = Utils.vf.createIRI(val);
×
462
                    SpaceMemberRole candidate = roleMap.get(pred);
×
463
                    if (candidate != null) { role = candidate; break; }
×
464
                }
465
            }
466
            // Gate by role-predicate match: only admit members whose predicate
467
            // corresponds to a role that was attached to this space by an admin
468
            // (roleMap is populated from validated gen:RoleAssignment rows
469
            // in loadRoles).
470
            if (role == null) continue;
×
471
            IRI memberId = Utils.vf.createIRI(r.get("member"));
×
472
            String np = r.get("np");
×
473
            users.computeIfAbsent(memberId, k -> new HashSet<>())
×
474
                    .add(new SpaceMemberRoleRef(role, np));
×
475
        }
×
476
    }
×
477

478
    private void readCoreData() {
479
        for (Statement st : rootNanopub.getAssertion()) {
36✔
480
            if (st.getSubject().stringValue().equals(getId())) {
21!
481
                if (st.getPredicate().equals(OWL.SAMEAS) && st.getObject() instanceof IRI objIri) {
42!
482
                    altIds.add(objIri.stringValue());
21✔
483
                } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
15✔
484
                    description = st.getObject().stringValue();
18✔
485
                } else if (st.getPredicate().stringValue().equals("http://schema.org/startDate")) {
18✔
486
                    try {
487
                        startDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
488
                    } catch (IllegalArgumentException ex) {
×
489
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
490
                    }
3✔
491
                } else if (st.getPredicate().stringValue().equals("http://schema.org/endDate")) {
18✔
492
                    try {
493
                        endDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
494
                    } catch (IllegalArgumentException ex) {
×
495
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
496
                    }
3✔
497
                } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ADMIN) && st.getObject() instanceof IRI obj) {
42!
498
                    if (!rootAdmins.contains(obj)) rootAdmins.add(obj);
33!
499
                } else if (st.getPredicate().equals(NTEMPLATE.HAS_DEFAULT_PROVENANCE) && st.getObject() instanceof IRI obj) {
15!
500
                    defaultProvenance = obj;
×
501
                }
502
            }
503
        }
3✔
504
    }
3✔
505

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