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

knowledgepixels / nanodash / 26599283002

28 May 2026 08:07PM UTC coverage: 20.732% (-0.03%) from 20.76%
26599283002

Pull #475

github

web-flow
Merge 59b029ab3 into 03dc49b33
Pull Request #475: feat: render /spaces as a single sortable All Spaces table

1003 of 6148 branches covered (16.31%)

Branch coverage included in aggregate %.

2584 of 11154 relevant lines covered (23.17%)

3.31 hits per line

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

27.41
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
    public String getNamespace() {
152
        // FIXME this will be removed in the future
153
        return null;
×
154
    }
155

156
    /**
157
     * Get the label of the space.
158
     *
159
     * @return The space label.
160
     */
161
    @Override
162
    public String getLabel() {
163
        return label;
×
164
    }
165

166
    /**
167
     * Get the type of the space.
168
     *
169
     * @return The space type.
170
     */
171
    public String getType() {
172
        return type;
×
173
    }
174

175
    /**
176
     * Get the start date of the space.
177
     *
178
     * @return The start date as a Calendar object, or null if not set.
179
     */
180
    public Calendar getStartDate() {
181
        return startDate;
×
182
    }
183

184
    /**
185
     * Get the end date of the space.
186
     *
187
     * @return The end date as a Calendar object, or null if not set.
188
     */
189
    public Calendar getEndDate() {
190
        return endDate;
×
191
    }
192

193
    /**
194
     * Get a simplified label for the type of space by removing any namespace prefix.
195
     *
196
     * @return The simplified type label.
197
     */
198
    public String getTypeLabel() {
199
        return type.replaceFirst("^.*/", "");
×
200
    }
201

202
    /**
203
     * Get the description of the space.
204
     *
205
     * @return The description string.
206
     */
207
    public String getDescription() {
208
        return description;
×
209
    }
210

211

212
    /**
213
     * Get the list of admins in this space.
214
     *
215
     * @return List of admin IRIs.
216
     */
217
    public List<IRI> getAdmins() {
218
        return currentData().admins;
×
219
    }
220

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

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

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

252
    /**
253
     * Check if a public key is associated with an admin of this space.
254
     *
255
     * @param pubkey The public key hash to check.
256
     * @return true if the public key is associated with an admin, false otherwise.
257
     */
258
    public boolean isAdminPubkey(String pubkey) {
259
        if (pubkey == null) return false;
×
260
        ApiResponse resp = ApiCache.retrieveResponseSync(
×
261
                new QueryRef(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES, spaceParams(allSpaceIris())), false);
×
262
        if (resp == null) return false;
×
263
        for (ApiResponseEntry r : resp.getData()) {
×
264
            if (pubkey.equals(r.get("pkh"))) return true;
×
265
        }
×
266
        return false;
×
267
    }
268

269
    /**
270
     * Get the default provenance IRI for this space.
271
     *
272
     * @return The default provenance IRI, or null if not set.
273
     */
274
    public IRI getDefaultProvenance() {
275
        return defaultProvenance;
×
276
    }
277

278
    /**
279
     * Get the roles defined in this space.
280
     *
281
     * @return List of roles.
282
     */
283
    public List<SpaceMemberRoleRef> getRoles() {
284
        return currentData().roles;
×
285
    }
286

287
    /**
288
     * Get the super ID of the space.
289
     *
290
     * @return Always returns null. Use getIdSuperspace() instead.
291
     */
292
    public String getSuperId() {
293
        return null;
×
294
    }
295

296
    /**
297
     * Get alternative IDs for the space.
298
     *
299
     * @return List of alternative IDs.
300
     */
301
    public List<String> getAltIDs() {
302
        return altIds;
9✔
303
    }
304

305
    @Override
306
    public void forceRefresh(long waitMillis) {
307
        super.forceRefresh(waitMillis);
×
308
        Multimap<String, String> params = spaceParams(allSpaceIris());
×
309
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_ADMINS, params), waitMillis);
×
310
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_ROLES, params), waitMillis);
×
311
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_MEMBERS, params), waitMillis);
×
312
        ApiCache.clearCache(new QueryRef(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES, params), waitMillis);
×
313
    }
×
314

315
    private List<String> allSpaceIris() {
316
        List<String> iris = new ArrayList<>(altIds.size() + 1);
×
317
        iris.add(getId());
×
318
        iris.addAll(altIds);
×
319
        return iris;
×
320
    }
321

322
    private synchronized SpaceData currentData() {
323
        Multimap<String, String> params = spaceParams(allSpaceIris());
×
324
        ApiResponse adminsResp = ApiCache.retrieveResponseSync(
×
325
                new QueryRef(QueryApiAccess.GET_SPACE_ADMINS, params), false);
326
        ApiResponse rolesResp = ApiCache.retrieveResponseSync(
×
327
                new QueryRef(QueryApiAccess.GET_SPACE_ROLES, params), false);
328
        ApiResponse membersResp = ApiCache.retrieveResponseSync(
×
329
                new QueryRef(QueryApiAccess.GET_SPACE_MEMBERS, params), false);
330
        if (adminsResp == cachedAdminsResp && rolesResp == cachedRolesResp && membersResp == cachedMembersResp) {
×
331
            return cachedData;
×
332
        }
333
        SpaceData newData = buildData(adminsResp, rolesResp, membersResp);
×
334
        cachedAdminsResp = adminsResp;
×
335
        cachedRolesResp = rolesResp;
×
336
        cachedMembersResp = membersResp;
×
337
        cachedData = newData;
×
338
        return newData;
×
339
    }
340

341
    private SpaceData buildData(ApiResponse adminsResp, ApiResponse rolesResp, ApiResponse membersResp) {
342
        List<IRI> admins = new ArrayList<>();
×
343
        Map<IRI, Set<SpaceMemberRoleRef>> users = new HashMap<>();
×
344
        List<SpaceMemberRoleRef> roles = new ArrayList<>();
×
345
        Map<IRI, SpaceMemberRole> roleMap = new HashMap<>();
×
346

347
        // Seed from rootNanopub-derived state
348
        for (IRI rootAdmin : rootAdmins) {
×
349
            admins.add(rootAdmin);
×
350
            users.computeIfAbsent(rootAdmin, k -> new HashSet<>())
×
351
                    .add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, rootNanopubId));
×
352
        }
×
353
        roles.add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, null));
×
354
        roleMap.put(KPXL_TERMS.HAS_ADMIN_PREDICATE, SpaceMemberRole.ADMIN_ROLE);
×
355

356
        loadAdmins(admins, users, adminsResp);
×
357
        admins.sort(User.getUserData().userComparator);
×
358

359
        loadRoles(roles, roleMap, rolesResp);
×
360
        loadMembers(users, roleMap, membersResp);
×
361

362
        return new SpaceData(admins, users, roles, roleMap);
×
363
    }
364

365
    private static Multimap<String, String> spaceParams(List<String> spaceIris) {
366
        Multimap<String, String> params = ArrayListMultimap.create();
×
367
        for (String iri : spaceIris) params.put("space", iri);
×
368
        return params;
×
369
    }
370

371
    private static void loadAdmins(List<IRI> admins, Map<IRI, Set<SpaceMemberRoleRef>> users, ApiResponse resp) {
372
        if (resp == null) return;
×
373
        for (ApiResponseEntry r : resp.getData()) {
×
374
            IRI adminId = Utils.vf.createIRI(r.get("agent"));
×
375
            String np = r.get("np");
×
376
            if (admins.contains(adminId)) continue;
×
377
            admins.add(adminId);
×
378
            users.computeIfAbsent(adminId, k -> new HashSet<>())
×
379
                    .add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, np));
×
380
        }
×
381
    }
×
382

383
    private static void loadRoles(List<SpaceMemberRoleRef> roles, Map<IRI, SpaceMemberRole> roleMap, ApiResponse resp) {
384
        if (resp == null) return;
×
385
        for (ApiResponseEntry r : resp.getData()) {
×
386
            ApiResponseEntry entry = new ApiResponseEntry();
×
387
            for (String k : List.of("role", "roleLabel", "roleName", "roleTitle",
×
388
                    "roleAssignmentTemplate", "regularProperties", "inverseProperties")) {
389
                String v = r.get(k);
×
390
                if (v != null && !v.isEmpty()) entry.add(k, v);
×
391
            }
×
392
            SpaceMemberRole role = new SpaceMemberRole(entry);
×
393
            String raNp = r.get("ra_np");
×
394
            if (raNp != null && raNp.isEmpty()) raNp = null;
×
395
            roles.add(new SpaceMemberRoleRef(role, raNp));
×
396
            for (IRI p : role.getRegularProperties()) roleMap.put(p, role);
×
397
            for (IRI p : role.getInverseProperties()) roleMap.put(p, role);
×
398
        }
×
399
    }
×
400

401
    private static void loadMembers(Map<IRI, Set<SpaceMemberRoleRef>> users, Map<IRI, SpaceMemberRole> roleMap, ApiResponse resp) {
402
        // Pulls RI candidates from the extraction graph (npa:spacesGraph) rather
403
        // than the validated state graph. The materialiser is correctly stricter
404
        // (e.g., it stops admitting RIs when their role declaration is
405
        // invalidated, even if the role assignment still points at the old IRI;
406
        // and the design admits non-admin-published observer-tier RIs only via
407
        // AccountState self-evidence, dropping any whose agents aren't in the
408
        // trust state). Nanodash matches the looser pre-migration semantic of
409
        // get-space-members: any RI whose predicate corresponds to a role
410
        // attached to the space (the admin-gating happens server-side on the
411
        // role attachment, not on the per-member nanopub) is admitted,
412
        // regardless of who signed the member RI. Invalidated rows are filtered
413
        // server-side via the npx:invalidates triple in npa:graph.
414
        if (resp == null) return;
×
415
        for (ApiResponseEntry r : resp.getData()) {
×
416
            SpaceMemberRole role = null;
×
417
            for (String key : new String[] {"regProp", "invProp"}) {
×
418
                String val = r.get(key);
×
419
                if (val != null && !val.isEmpty()) {
×
420
                    IRI pred = Utils.vf.createIRI(val);
×
421
                    SpaceMemberRole candidate = roleMap.get(pred);
×
422
                    if (candidate != null) { role = candidate; break; }
×
423
                }
424
            }
425
            // Gate by role-predicate match: only admit members whose predicate
426
            // corresponds to a role that was attached to this space by an admin
427
            // (roleMap is populated from validated gen:RoleAssignment rows
428
            // in loadRoles).
429
            if (role == null) continue;
×
430
            IRI memberId = Utils.vf.createIRI(r.get("member"));
×
431
            String np = r.get("np");
×
432
            users.computeIfAbsent(memberId, k -> new HashSet<>())
×
433
                    .add(new SpaceMemberRoleRef(role, np));
×
434
        }
×
435
    }
×
436

437
    private void readCoreData() {
438
        for (Statement st : rootNanopub.getAssertion()) {
36✔
439
            if (st.getSubject().stringValue().equals(getId())) {
21!
440
                if (st.getPredicate().equals(OWL.SAMEAS) && st.getObject() instanceof IRI objIri) {
42!
441
                    altIds.add(objIri.stringValue());
21✔
442
                } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
15✔
443
                    description = st.getObject().stringValue();
18✔
444
                } else if (st.getPredicate().stringValue().equals("http://schema.org/startDate")) {
18✔
445
                    try {
446
                        startDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
447
                    } catch (IllegalArgumentException ex) {
×
448
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
449
                    }
3✔
450
                } else if (st.getPredicate().stringValue().equals("http://schema.org/endDate")) {
18✔
451
                    try {
452
                        endDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
453
                    } catch (IllegalArgumentException ex) {
×
454
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
455
                    }
3✔
456
                } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ADMIN) && st.getObject() instanceof IRI obj) {
42!
457
                    if (!rootAdmins.contains(obj)) rootAdmins.add(obj);
33!
458
                } else if (st.getPredicate().equals(NTEMPLATE.HAS_DEFAULT_PROVENANCE) && st.getObject() instanceof IRI obj) {
15!
459
                    defaultProvenance = obj;
×
460
                }
461
            }
462
        }
3✔
463
    }
3✔
464

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