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

knowledgepixels / nanodash / 27741419211

18 Jun 2026 06:35AM UTC coverage: 26.602% (-0.4%) from 26.963%
27741419211

Pull #484

github

web-flow
Merge eb7ba5ef8 into 0f6281554
Pull Request #484: Space-ref disambiguation: conflict notice, claimants overview, ref-pinned pages

1552 of 6853 branches covered (22.65%)

Branch coverage included in aggregate %.

3418 of 11830 relevant lines covered (28.89%)

4.25 hits per line

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

18.67
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
    // Space-ref identity (space IRI + root-definition NPID). Populated only by the
33
    // ref-aware get-spaces query (v3); null/empty with the pre-v3 query. See
34
    // docs/space-ref-identity.md. refRootId = the representative ref's root nanopub;
35
    // refRoots = the roots of all distinct refs that claim this space IRI.
36
    private String refRootId = null;
9✔
37
    private Set<String> refRoots = Collections.emptySet();
9✔
38

39
    // Core data — derived directly from the root nanopub assertion.
40
    private final List<String> altIds = new ArrayList<>();
15✔
41
    private final List<IRI> rootAdmins = new ArrayList<>();
15✔
42
    private String description = null;
9✔
43
    private Calendar startDate, endDate;
44
    private IRI defaultProvenance = null;
9✔
45

46
    // Derived data — admins / members / roles loaded from the spaces repo and
47
    // memoised by ApiResponse object identity (rebuild when ApiCache returns a
48
    // fresh response for any of the three underlying queries).
49
    private volatile ApiResponse cachedAdminsResp, cachedRolesResp, cachedMembersResp;
50
    private volatile SpaceData cachedData = SpaceData.EMPTY;
9✔
51
    // Per-ref membership/role data for non-representative refs (?root=-pinned pages), memoised
52
    // by ApiResponse identity like cachedData above. Transient: rebuilt on demand. See refData().
53
    private transient Map<String, SpaceData> refDataMemos;
54
    private transient Map<String, ApiResponse[]> refMemoResps;
55

56
    private static class SpaceData implements Serializable {
57

58
        final List<IRI> admins;
59
        final Map<IRI, Set<SpaceMemberRoleRef>> users;
60
        final List<SpaceMemberRoleRef> roles;
61
        final Map<IRI, SpaceMemberRole> roleMap;
62
        // Members whose every role instantiation is un-introduced/unvalidated (not in the
63
        // trust-validated state). Shown, but flagged. See docs/space-ref-identity.md.
64
        final Set<IRI> unvalidatedMembers;
65

66
        SpaceData(List<IRI> admins, Map<IRI, Set<SpaceMemberRoleRef>> users,
67
                  List<SpaceMemberRoleRef> roles, Map<IRI, SpaceMemberRole> roleMap,
68
                  Set<IRI> unvalidatedMembers) {
6✔
69
            this.admins = admins;
9✔
70
            this.users = users;
9✔
71
            this.roles = roles;
9✔
72
            this.roleMap = roleMap;
9✔
73
            this.unvalidatedMembers = unvalidatedMembers;
9✔
74
        }
3✔
75

76
        static final SpaceData EMPTY = new SpaceData(
9✔
77
                Collections.emptyList(), Collections.emptyMap(),
6✔
78
                Collections.emptyList(), Collections.emptyMap(), Collections.emptySet());
15✔
79
    }
80

81
    private static final Map<String, String> TYPE_EMOJIS = Map.ofEntries(
27✔
82
            Map.entry("Alliance", "🤝"),
18✔
83
            Map.entry("Consortium", "☂️"),
18✔
84
            Map.entry("Organization", "🏢"),
18✔
85
            Map.entry("Taskforce", "🎯"),
18✔
86
            Map.entry("Division", "🏗️"),
18✔
87
            Map.entry("Taskunit", "⚙️"),
18✔
88
            Map.entry("Group", "👥"),
18✔
89
            Map.entry("Project", "🚀"),
18✔
90
            Map.entry("Program", "📋"),
18✔
91
            Map.entry("Initiative", "💡"),
18✔
92
            Map.entry("Outlet", "📰"),
18✔
93
            Map.entry("Campaign", "📣"),
18✔
94
            Map.entry("Community", "🌐"),
18✔
95
            Map.entry("Event", "🎪")
6✔
96
    );
97

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

108
    Space(ApiResponseEntry resp) {
109
        super(resp.get("space"));
15✔
110
        initSpace(this);
9✔
111
        this.label = resp.get("label");
15✔
112
        this.type = resp.get("type");
15✔
113
        this.rootNanopubId = resp.get("np");
15✔
114
        this.rootNanopub = Utils.getAsNanopub(rootNanopubId);
15✔
115
        this.refRootId = resp.get("ref_root");
15✔
116
        readCoreData();
6✔
117
    }
3✔
118

119
    void updateFromApi(ApiResponseEntry resp) {
120
        this.refRootId = resp.get("ref_root");
10✔
121
        String newNpId = resp.get("np");
8✔
122
        if (!newNpId.equals(this.rootNanopubId)) {
10!
123
            this.label = resp.get("label");
×
124
            this.type = resp.get("type");
×
125
            this.rootNanopubId = newNpId;
×
126
            this.rootNanopub = Utils.getAsNanopub(newNpId);
×
127
            altIds.clear();
×
128
            rootAdmins.clear();
×
129
            description = null;
×
130
            startDate = null;
×
131
            endDate = null;
×
132
            defaultProvenance = null;
×
133
            readCoreData();
×
134
            setDataNeedsUpdate();
×
135
        }
136
    }
2✔
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-definition nanopub ID of this space's ref (the representative ref
159
     * when several refs claim the same space IRI). The space ref identity is the space
160
     * IRI plus this root NPID. Populated only by the ref-aware get-spaces query (v3);
161
     * null with the pre-v3 query. See docs/space-ref-identity.md.
162
     *
163
     * @return The representative ref's root nanopub ID, or null if unknown.
164
     */
165
    public String getRefRootId() {
166
        return refRootId;
×
167
    }
168

169
    /**
170
     * Scope a space's view displays to its representative ref so a multi-ref identifier doesn't
171
     * merge Content-tab displays across rival definitions. {@code ?root=}-pinned pages pass the
172
     * pinned ref explicitly via {@link #getTopLevelViewDisplays(String)} instead.
173
     */
174
    @Override
175
    protected String getViewDisplayRefRoot() {
176
        return refRootId;
×
177
    }
178

179
    /**
180
     * Get the root nanopub IDs of all distinct refs that claim this space IRI. More than
181
     * one means several space definitions claim the same IRI (distinct spaces, not a
182
     * conflict to resolve). Empty with the pre-v3 query. See docs/space-ref-identity.md.
183
     *
184
     * @return The set of ref root nanopub IDs (possibly empty).
185
     */
186
    public Set<String> getRefRoots() {
187
        return refRoots;
×
188
    }
189

190
    /**
191
     * Set the root nanopub IDs of all distinct refs claiming this space IRI. Called by
192
     * the repository while building the space listing from the ref-aware get-spaces query.
193
     *
194
     * @param refRoots The set of ref root nanopub IDs (may be null/empty).
195
     */
196
    public void setRefRoots(Set<String> refRoots) {
197
        this.refRoots = (refRoots == null || refRoots.isEmpty()) ? Collections.emptySet() : refRoots;
24!
198
    }
3✔
199

200
    /**
201
     * Get the number of distinct refs claiming this space IRI (at least 1).
202
     *
203
     * @return The ref count (1 when ref data is unavailable).
204
     */
205
    public int getRefCount() {
206
        return Math.max(1, refRoots.size());
×
207
    }
208

209
    /**
210
     * Whether this space IRI is claimed by multiple refs (root definitions) with
211
     * <em>differing</em> admin sets — i.e. rival definitions, as opposed to same-owner
212
     * stray duplicates (which share an admin set and stay collapsed). Resolves each ref's
213
     * validated admin set via the ref-scoped admins query and reports a conflict as soon as
214
     * two of them differ. Returns false for the common single-ref case. See
215
     * docs/space-ref-identity.md.
216
     *
217
     * @return true if at least two refs of this IRI have different admin agent sets
218
     */
219
    public boolean hasConflictingRefs() {
220
        if (refRoots.size() < 2) return false;
×
221
        Map<String, Set<String>> adminsByRoot = claimantAdminsByRoot();
×
222
        Set<Set<String>> distinctAdminSets = new HashSet<>();
×
223
        for (String rootNp : refRoots) {
×
224
            distinctAdminSets.add(adminsByRoot.getOrDefault(rootNp, Collections.emptySet()));
×
225
            if (distinctAdminSets.size() > 1) return true;
×
226
        }
×
227
        return false;
×
228
    }
229

230
    /**
231
     * All refs (root definitions) claiming this space's IRI, each with its validated admin
232
     * agents — the representative (default) ref first. Used by the disambiguation claimants
233
     * view. See docs/space-ref-identity.md.
234
     *
235
     * @return the claimants, default ref first (a singleton for the common single-ref case)
236
     */
237
    public List<RefClaimant> getRefClaimants() {
238
        List<String> ordered = new ArrayList<>();
×
239
        if (refRootId != null && !refRootId.isEmpty()) ordered.add(refRootId);
×
240
        for (String root : refRoots) {
×
241
            if (!root.equals(refRootId)) ordered.add(root);
×
242
        }
×
243
        Map<String, Set<String>> adminsByRoot = claimantAdminsByRoot();
×
244
        List<RefClaimant> claimants = new ArrayList<>();
×
245
        for (String root : ordered) {
×
246
            claimants.add(new RefClaimant(root, adminsByRoot.getOrDefault(root, Collections.emptySet()), root.equals(refRootId)));
×
247
        }
×
248
        return claimants;
×
249
    }
250

251
    /** A single ref (root definition) claiming a space's IRI, with its validated admins. */
252
    public static final class RefClaimant implements Serializable {
253
        private final String rootNp;
254
        private final List<String> admins;
255
        private final boolean representative;
256

257
        RefClaimant(String rootNp, Set<String> admins, boolean representative) {
×
258
            this.rootNp = rootNp;
×
259
            this.admins = new ArrayList<>(admins);
×
260
            this.representative = representative;
×
261
        }
×
262

263
        /** The root-definition nanopub ID identifying this ref. */
264
        public String getRootNp() {
265
            return rootNp;
×
266
        }
267

268
        /** The validated admin agent IRIs of this ref. */
269
        public List<String> getAdmins() {
270
            return admins;
×
271
        }
272

273
        /** Whether this is the representative (default) ref — the one shown when no specific ref is requested. */
274
        public boolean isRepresentative() {
275
            return representative;
×
276
        }
277
    }
278

279
    /**
280
     * The validated admin agent IRIs of every ref claiming this space IRI, keyed by the ref's
281
     * root nanopub — a single {@code list-space-claimants} fetch (cached), replacing the per-ref
282
     * {@code get-space-admins} fan-out. Used by {@link #hasConflictingRefs()} and
283
     * {@link #getRefClaimants()}.
284
     */
285
    private Map<String, Set<String>> claimantAdminsByRoot() {
286
        Map<String, Set<String>> map = new HashMap<>();
×
287
        ApiResponse resp = ApiCache.retrieveResponseSync(
×
288
                new QueryRef(QueryApiAccess.LIST_SPACE_CLAIMANTS, "space", getId()), false);
×
289
        if (resp != null) {
×
290
            for (ApiResponseEntry r : resp.getData()) {
×
291
                String root = r.get("root");
×
292
                if (root == null || root.isEmpty()) continue;
×
293
                Set<String> admins = new HashSet<>();
×
294
                String adminsStr = r.get("admins_multi_iri");
×
295
                if (adminsStr != null && !adminsStr.isEmpty()) {
×
296
                    for (String a : adminsStr.split(" ")) {
×
297
                        if (!a.isEmpty()) admins.add(a);
×
298
                    }
299
                }
300
                map.put(root, admins);
×
301
            }
×
302
        }
303
        return map;
×
304
    }
305

306
    /**
307
     * Get the root nanopublication of the space.
308
     *
309
     * @return The root Nanopub object.
310
     */
311
    @Override
312
    public Nanopub getNanopub() {
313
        return rootNanopub;
×
314
    }
315

316
    @Override
317
    protected Set<IRI> getOwnClasses() {
318
        return Set.of(KPXL_TERMS.SPACE);
×
319
    }
320

321
    @Override
322
    public String getNamespace() {
323
        // FIXME this will be removed in the future
324
        return null;
×
325
    }
326

327
    /**
328
     * Get the label of the space.
329
     *
330
     * @return The space label.
331
     */
332
    @Override
333
    public String getLabel() {
334
        return label;
×
335
    }
336

337
    /**
338
     * Get the type of the space.
339
     *
340
     * @return The space type.
341
     */
342
    public String getType() {
343
        return type;
×
344
    }
345

346
    /**
347
     * Get the start date of the space.
348
     *
349
     * @return The start date as a Calendar object, or null if not set.
350
     */
351
    public Calendar getStartDate() {
352
        return startDate;
×
353
    }
354

355
    /**
356
     * Get the end date of the space.
357
     *
358
     * @return The end date as a Calendar object, or null if not set.
359
     */
360
    public Calendar getEndDate() {
361
        return endDate;
×
362
    }
363

364
    /**
365
     * Get a simplified label for the type of space by removing any namespace prefix.
366
     *
367
     * @return The simplified type label.
368
     */
369
    public String getTypeLabel() {
370
        return type.replaceFirst("^.*/", "");
×
371
    }
372

373
    /**
374
     * Get the description of the space.
375
     *
376
     * @return The description string.
377
     */
378
    public String getDescription() {
379
        return description;
×
380
    }
381

382

383
    /**
384
     * Get the list of admins in this space.
385
     *
386
     * @return List of admin IRIs.
387
     */
388
    public List<IRI> getAdmins() {
389
        return currentData().admins;
×
390
    }
391

392
    /**
393
     * Get the list of members in this space.
394
     *
395
     * @return List of member IRIs.
396
     */
397
    public List<IRI> getUsers() {
398
        List<IRI> users = new ArrayList<>(currentData().users.keySet());
×
399
        users.sort(User.getUserData().userComparator);
×
400
        return users;
×
401
    }
402

403
    /**
404
     * Whether a member's membership is trust-validated (their key has a trust-approved
405
     * AccountState from an accepted introduction). Admins and any member with at least one
406
     * validated role instantiation are considered validated; un-introduced self-declared
407
     * members are not. See docs/space-ref-identity.md.
408
     *
409
     * @param userId The IRI of the member.
410
     * @return false only when the member is present but every instantiation is unvalidated.
411
     */
412
    public boolean isMemberValidated(IRI userId) {
413
        return !currentData().unvalidatedMembers.contains(userId);
×
414
    }
415

416
    /**
417
     * Get the roles of a specific member in this space.
418
     *
419
     * @param userId The IRI of the member.
420
     * @return Set of roles assigned to the member, or null if the member is not part of this space.
421
     */
422
    public Set<SpaceMemberRoleRef> getMemberRoles(IRI userId) {
423
        return currentData().users.get(userId);
×
424
    }
425

426
    /**
427
     * Check if a user is a member of this space.
428
     *
429
     * @param userId The IRI of the user to check.
430
     * @return true if the user is a member, false otherwise.
431
     */
432
    public boolean isMember(IRI userId) {
433
        return currentData().users.containsKey(userId);
×
434
    }
435

436
    /**
437
     * Get the highest role tier the given user holds in this space, as a numeric
438
     * rank for threshold comparisons (admin {@literal >} maintainer {@literal >}
439
     * member {@literal >} observer). A user holding no role gets
440
     * {@link SpaceMemberRole#EVERYONE_RANK}.
441
     *
442
     * @param userId The IRI of the user.
443
     * @return the highest tier rank held, or the "everyone" floor if none.
444
     */
445
    public int userTier(IRI userId) {
446
        return userTier(userId, null);
×
447
    }
448

449
    /**
450
     * Like {@link #userTier(IRI)} but resolved against a specific ref (root definition)
451
     * rather than this space's representative ref. Used to gate rendering on a
452
     * {@code ?root=}-pinned page so authority reflects the ref actually being viewed: a
453
     * user who is, say, an admin under the representative ref but holds no role under the
454
     * pinned ref gets the "everyone" floor here. A null/empty {@code refRoot} (or one equal
455
     * to this space's own ref) falls back to the representative ref. See docs/space-ref-identity.md.
456
     *
457
     * @param userId  The IRI of the user.
458
     * @param refRoot The ref's root nanopub to scope to, or null for the representative ref.
459
     * @return the highest tier rank held under that ref, or the "everyone" floor if none.
460
     */
461
    public int userTier(IRI userId, String refRoot) {
462
        Set<SpaceMemberRoleRef> roles = dataForRef(refRoot).users.get(userId);
×
463
        if (roles == null || roles.isEmpty()) return SpaceMemberRole.EVERYONE_RANK;
×
464
        int max = SpaceMemberRole.EVERYONE_RANK;
×
465
        for (SpaceMemberRoleRef ref : roles) {
×
466
            max = Math.max(max, ref.getRole().getTierRank());
×
467
        }
×
468
        return max;
×
469
    }
470

471
    /**
472
     * Check whether the given user holds the specific role (by role IRI) in this
473
     * space.
474
     *
475
     * @param userId  The IRI of the user.
476
     * @param roleIri The specific role IRI.
477
     * @return true if the user holds that exact role, false otherwise.
478
     */
479
    public boolean viewerHoldsRole(IRI userId, IRI roleIri) {
480
        return viewerHoldsRole(userId, roleIri, null);
×
481
    }
482

483
    /**
484
     * Like {@link #viewerHoldsRole(IRI, IRI)} but resolved against a specific ref (root
485
     * definition) rather than this space's representative ref, for ref-correct gating on a
486
     * {@code ?root=}-pinned page. A null/empty {@code refRoot} (or one equal to this space's
487
     * own ref) falls back to the representative ref. See docs/space-ref-identity.md.
488
     *
489
     * @param userId  The IRI of the user.
490
     * @param roleIri The specific role IRI.
491
     * @param refRoot The ref's root nanopub to scope to, or null for the representative ref.
492
     * @return true if the user holds that exact role under that ref, false otherwise.
493
     */
494
    public boolean viewerHoldsRole(IRI userId, IRI roleIri, String refRoot) {
495
        Set<SpaceMemberRoleRef> roles = dataForRef(refRoot).users.get(userId);
×
496
        if (roles == null) return false;
×
497
        for (SpaceMemberRoleRef ref : roles) {
×
498
            if (ref.getRole().getId().equals(roleIri)) return true;
×
499
        }
×
500
        return false;
×
501
    }
502

503
    /**
504
     * Check if a public key is associated with an admin of this space.
505
     *
506
     * @param pubkey The public key hash to check.
507
     * @return true if the public key is associated with an admin, false otherwise.
508
     */
509
    public boolean isAdminPubkey(String pubkey) {
510
        if (pubkey == null) return false;
×
511
        ApiResponse resp = ApiCache.retrieveResponseSync(
×
512
                spaceQueryRef(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES_REF, QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES), false);
×
513
        if (resp == null) return false;
×
514
        for (ApiResponseEntry r : resp.getData()) {
×
515
            if (pubkey.equals(r.get("pkh"))) return true;
×
516
        }
×
517
        return false;
×
518
    }
519

520
    /**
521
     * Get the default provenance IRI for this space.
522
     *
523
     * @return The default provenance IRI, or null if not set.
524
     */
525
    public IRI getDefaultProvenance() {
526
        return defaultProvenance;
×
527
    }
528

529
    /**
530
     * Get the roles defined in this space.
531
     *
532
     * @return List of roles.
533
     */
534
    public List<SpaceMemberRoleRef> getRoles() {
535
        return currentData().roles;
×
536
    }
537

538
    /**
539
     * Get the super ID of the space.
540
     *
541
     * @return Always returns null. Use getIdSuperspace() instead.
542
     */
543
    public String getSuperId() {
544
        return null;
×
545
    }
546

547
    /**
548
     * Get alternative IDs for the space.
549
     *
550
     * @return List of alternative IDs.
551
     */
552
    public List<String> getAltIDs() {
553
        return altIds;
9✔
554
    }
555

556
    @Override
557
    public void forceRefresh(long waitMillis) {
558
        super.forceRefresh(waitMillis);
×
559
        ApiCache.clearCache(spaceQueryRef(QueryApiAccess.GET_SPACE_ADMINS_REF, QueryApiAccess.GET_SPACE_ADMINS), waitMillis);
×
560
        ApiCache.clearCache(spaceQueryRef(QueryApiAccess.GET_SPACE_ROLES_REF, QueryApiAccess.GET_SPACE_ROLES), waitMillis);
×
561
        ApiCache.clearCache(spaceQueryRef(QueryApiAccess.GET_SPACE_MEMBERS_REF, QueryApiAccess.GET_SPACE_MEMBERS), waitMillis);
×
562
        ApiCache.clearCache(spaceQueryRef(QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES_REF, QueryApiAccess.GET_SPACE_ADMIN_PUBKEY_HASHES), waitMillis);
×
563
    }
×
564

565
    private List<String> allSpaceIris() {
566
        List<String> iris = new ArrayList<>(altIds.size() + 1);
×
567
        iris.add(getId());
×
568
        iris.addAll(altIds);
×
569
        return iris;
×
570
    }
571

572
    private synchronized SpaceData currentData() {
573
        ApiResponse adminsResp = ApiCache.retrieveResponseSync(
×
574
                spaceQueryRef(QueryApiAccess.GET_SPACE_ADMINS_REF, QueryApiAccess.GET_SPACE_ADMINS), false);
×
575
        ApiResponse rolesResp = ApiCache.retrieveResponseSync(
×
576
                spaceQueryRef(QueryApiAccess.GET_SPACE_ROLES_REF, QueryApiAccess.GET_SPACE_ROLES), false);
×
577
        ApiResponse membersResp = ApiCache.retrieveResponseSync(
×
578
                spaceQueryRef(QueryApiAccess.GET_SPACE_MEMBERS_REF, QueryApiAccess.GET_SPACE_MEMBERS), false);
×
579
        if (adminsResp == cachedAdminsResp && rolesResp == cachedRolesResp && membersResp == cachedMembersResp) {
×
580
            return cachedData;
×
581
        }
582
        SpaceData newData = buildData(adminsResp, rolesResp, membersResp, true);
×
583
        cachedAdminsResp = adminsResp;
×
584
        cachedRolesResp = rolesResp;
×
585
        cachedMembersResp = membersResp;
×
586
        cachedData = newData;
×
587
        return newData;
×
588
    }
589

590
    /**
591
     * Membership/role data scoped to a specific ref (root definition). Returns the
592
     * representative-ref {@link #currentData()} when {@code refRoot} is null/empty or is
593
     * this space's own ref; otherwise resolves the admins/roles/members of the requested
594
     * ref via the ref-scoped queries (keyed by {@code root_np} → {@code npa:forSpaceRef}).
595
     * Used for ref-correct authority on {@code ?root=}-pinned pages. See docs/space-ref-identity.md.
596
     */
597
    private SpaceData dataForRef(String refRoot) {
598
        if (refRoot == null || refRoot.isEmpty() || refRoot.equals(refRootId)) return currentData();
×
599
        return refData(refRoot);
×
600
    }
601

602
    private synchronized SpaceData refData(String refRoot) {
603
        ApiResponse adminsResp = ApiCache.retrieveResponseSync(rootNpQuery(QueryApiAccess.GET_SPACE_ADMINS_REF, refRoot), false);
×
604
        ApiResponse rolesResp = ApiCache.retrieveResponseSync(rootNpQuery(QueryApiAccess.GET_SPACE_ROLES_REF, refRoot), false);
×
605
        ApiResponse membersResp = ApiCache.retrieveResponseSync(rootNpQuery(QueryApiAccess.GET_SPACE_MEMBERS_REF, refRoot), false);
×
606
        if (refDataMemos == null) refDataMemos = new HashMap<>();
×
607
        if (refMemoResps == null) refMemoResps = new HashMap<>();
×
608
        SpaceData memo = refDataMemos.get(refRoot);
×
609
        if (memo != null && refMemoFresh(refRoot, adminsResp, rolesResp, membersResp)) return memo;
×
610
        // A non-representative ref's authority comes solely from its ref-scoped queries
611
        // (npa:forSpaceRef already includes its root-definition admins); do NOT seed this
612
        // space's representative root-declared admins, which belong to a rival definition.
613
        SpaceData data = buildData(adminsResp, rolesResp, membersResp, false);
×
614
        refDataMemos.put(refRoot, data);
×
615
        refMemoResps.put(refRoot, new ApiResponse[]{adminsResp, rolesResp, membersResp});
×
616
        return data;
×
617
    }
618

619
    private boolean refMemoFresh(String refRoot, ApiResponse a, ApiResponse r, ApiResponse m) {
620
        ApiResponse[] last = refMemoResps.get(refRoot);
×
621
        return last != null && last[0] == a && last[1] == r && last[2] == m;
×
622
    }
623

624
    private static QueryRef rootNpQuery(String queryId, String refRoot) {
625
        Multimap<String, String> p = ArrayListMultimap.create();
×
626
        p.put("root_np", refRoot);
×
627
        return new QueryRef(queryId, p);
×
628
    }
629

630
    private SpaceData buildData(ApiResponse adminsResp, ApiResponse rolesResp, ApiResponse membersResp, boolean seedFromRoot) {
631
        List<IRI> admins = new ArrayList<>();
×
632
        Map<IRI, Set<SpaceMemberRoleRef>> users = new HashMap<>();
×
633
        List<SpaceMemberRoleRef> roles = new ArrayList<>();
×
634
        Map<IRI, SpaceMemberRole> roleMap = new HashMap<>();
×
635

636
        // Seed from rootNanopub-derived state (representative ref only — see refData)
637
        if (seedFromRoot) {
×
638
            for (IRI rootAdmin : rootAdmins) {
×
639
                admins.add(rootAdmin);
×
640
                users.computeIfAbsent(rootAdmin, k -> new HashSet<>())
×
641
                        .add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, rootNanopubId));
×
642
            }
×
643
        }
644
        roles.add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, null));
×
645
        roleMap.put(KPXL_TERMS.HAS_ADMIN_PREDICATE, SpaceMemberRole.ADMIN_ROLE);
×
646

647
        loadAdmins(admins, users, adminsResp);
×
648
        admins.sort(User.getUserData().userComparator);
×
649

650
        loadRoles(roles, roleMap, rolesResp);
×
651
        Set<IRI> unvalidatedMembers = new HashSet<>();
×
652
        loadMembers(users, roleMap, membersResp, unvalidatedMembers);
×
653

654
        return new SpaceData(admins, users, roles, roleMap, unvalidatedMembers);
×
655
    }
656

657
    private static Multimap<String, String> spaceParams(List<String> spaceIris) {
658
        Multimap<String, String> params = ArrayListMultimap.create();
×
659
        for (String iri : spaceIris) params.put("space", iri);
×
660
        return params;
×
661
    }
662

663
    /**
664
     * Query reference for a per-space query that has both a ref-scoped variant (param
665
     * root_np = the ref's root nanopub) and an IRI-keyed variant. When the ref root is
666
     * known (ref-aware get-spaces) use the ref-scoped query so multi-ref spaces don't merge
667
     * authority across refs; otherwise fall back to the IRI-keyed query. See
668
     * docs/space-ref-identity.md.
669
     */
670
    private QueryRef spaceQueryRef(String refQueryId, String iriQueryId) {
671
        if (refRootId != null && !refRootId.isEmpty()) {
×
672
            Multimap<String, String> p = ArrayListMultimap.create();
×
673
            p.put("root_np", refRootId);
×
674
            return new QueryRef(refQueryId, p);
×
675
        }
676
        return new QueryRef(iriQueryId, spaceParams(allSpaceIris()));
×
677
    }
678

679
    private static void loadAdmins(List<IRI> admins, Map<IRI, Set<SpaceMemberRoleRef>> users, ApiResponse resp) {
680
        if (resp == null) return;
×
681
        for (ApiResponseEntry r : resp.getData()) {
×
682
            IRI adminId = Utils.vf.createIRI(r.get("agent"));
×
683
            String np = r.get("np");
×
684
            if (admins.contains(adminId)) continue;
×
685
            admins.add(adminId);
×
686
            users.computeIfAbsent(adminId, k -> new HashSet<>())
×
687
                    .add(new SpaceMemberRoleRef(SpaceMemberRole.ADMIN_ROLE, np));
×
688
        }
×
689
    }
×
690

691
    private static void loadRoles(List<SpaceMemberRoleRef> roles, Map<IRI, SpaceMemberRole> roleMap, ApiResponse resp) {
692
        if (resp == null) return;
×
693
        for (ApiResponseEntry r : resp.getData()) {
×
694
            ApiResponseEntry entry = new ApiResponseEntry();
×
695
            for (String k : List.of("role", "roleLabel", "roleName", "roleTitle",
×
696
                    "roleAssignmentTemplate", "regularProperties", "inverseProperties", "roleType")) {
697
                String v = r.get(k);
×
698
                if (v != null && !v.isEmpty()) entry.add(k, v);
×
699
            }
×
700
            SpaceMemberRole role = new SpaceMemberRole(entry);
×
701
            String raNp = r.get("ra_np");
×
702
            if (raNp != null && raNp.isEmpty()) raNp = null;
×
703
            roles.add(new SpaceMemberRoleRef(role, raNp));
×
704
            for (IRI p : role.getRegularProperties()) roleMap.put(p, role);
×
705
            for (IRI p : role.getInverseProperties()) roleMap.put(p, role);
×
706
        }
×
707
    }
×
708

709
    private static void loadMembers(Map<IRI, Set<SpaceMemberRoleRef>> users, Map<IRI, SpaceMemberRole> roleMap, ApiResponse resp, Set<IRI> unvalidatedMembers) {
710
        // Pulls RI candidates from the extraction graph (npa:spacesGraph) rather
711
        // than the validated state graph. The materialiser is correctly stricter
712
        // (e.g., it stops admitting RIs when their role declaration is
713
        // invalidated, even if the role assignment still points at the old IRI;
714
        // and the design admits non-admin-published observer-tier RIs only via
715
        // AccountState self-evidence, dropping any whose agents aren't in the
716
        // trust state). Nanodash matches the looser pre-migration semantic of
717
        // get-space-members: any RI whose predicate corresponds to a role
718
        // attached to the space (the admin-gating happens server-side on the
719
        // role attachment, not on the per-member nanopub) is admitted,
720
        // regardless of who signed the member RI. Invalidated rows are filtered
721
        // server-side via the npx:invalidates triple in npa:graph.
722
        if (resp == null) return;
×
723
        // The ref-scoped query carries a ?validated flag per row (true when the membership
724
        // is in the trust-validated state). When present, a member with no validated row is
725
        // flagged as unvalidated (shown, but un-introduced). The IRI-keyed fallback query
726
        // has no flag, so nothing is flagged.
727
        Set<IRI> seenMembers = new HashSet<>();
×
728
        Set<IRI> validatedMembers = new HashSet<>();
×
729
        boolean hasValidationFlag = false;
×
730
        for (ApiResponseEntry r : resp.getData()) {
×
731
            SpaceMemberRole role = null;
×
732
            for (String key : new String[] {"regProp", "invProp"}) {
×
733
                String val = r.get(key);
×
734
                if (val != null && !val.isEmpty()) {
×
735
                    IRI pred = Utils.vf.createIRI(val);
×
736
                    SpaceMemberRole candidate = roleMap.get(pred);
×
737
                    if (candidate != null) { role = candidate; break; }
×
738
                }
739
            }
740
            // Gate by role-predicate match: only admit members whose predicate
741
            // corresponds to a role that was attached to this space by an admin
742
            // (roleMap is populated from validated gen:RoleAssignment rows
743
            // in loadRoles).
744
            if (role == null) continue;
×
745
            IRI memberId = Utils.vf.createIRI(r.get("member"));
×
746
            String np = r.get("np");
×
747
            users.computeIfAbsent(memberId, k -> new HashSet<>())
×
748
                    .add(new SpaceMemberRoleRef(role, np));
×
749
            seenMembers.add(memberId);
×
750
            String validated = r.get("validated");
×
751
            if (validated != null) {
×
752
                hasValidationFlag = true;
×
753
                if ("true".equals(validated)) validatedMembers.add(memberId);
×
754
            }
755
        }
×
756
        if (hasValidationFlag) {
×
757
            for (IRI memberId : seenMembers) {
×
758
                if (!validatedMembers.contains(memberId)) unvalidatedMembers.add(memberId);
×
759
            }
×
760
        }
761
    }
×
762

763
    private void readCoreData() {
764
        for (Statement st : rootNanopub.getAssertion()) {
36✔
765
            if (st.getSubject().stringValue().equals(getId())) {
21!
766
                if (st.getPredicate().equals(OWL.SAMEAS) && st.getObject() instanceof IRI objIri) {
42!
767
                    altIds.add(objIri.stringValue());
21✔
768
                } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
15✔
769
                    description = st.getObject().stringValue();
18✔
770
                } else if (st.getPredicate().stringValue().equals("http://schema.org/startDate")) {
18✔
771
                    try {
772
                        startDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
773
                    } catch (IllegalArgumentException ex) {
×
774
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
775
                    }
3✔
776
                } else if (st.getPredicate().stringValue().equals("http://schema.org/endDate")) {
18✔
777
                    try {
778
                        endDate = DatatypeConverter.parseDateTime(st.getObject().stringValue());
18✔
779
                    } catch (IllegalArgumentException ex) {
×
780
                        logger.error("Failed to parse date {}", st.getObject().stringValue());
×
781
                    }
3✔
782
                } else if (st.getPredicate().equals(KPXL_TERMS.HAS_ADMIN) && st.getObject() instanceof IRI obj) {
42!
783
                    if (!rootAdmins.contains(obj)) rootAdmins.add(obj);
33!
784
                } else if (st.getPredicate().equals(NTEMPLATE.HAS_DEFAULT_PROVENANCE) && st.getObject() instanceof IRI obj) {
15!
785
                    defaultProvenance = obj;
×
786
                }
787
            }
788
        }
3✔
789
    }
3✔
790

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