|
| 1 | +function [sessions, notes] = datasetSessions(database, datasetSessionId, datasetPath, options) |
| 2 | +%DATASETSESSIONS The sessions of a V2 dataset: ingested and linked. |
| 3 | +% |
| 4 | +% [SESSIONS, NOTES] = ndi.v2.datasetSessions(DATABASE, DATASETSESSIONID, |
| 5 | +% DATASETPATH) reads a V2 dataset's own database (DATABASE, an ndi.database |
| 6 | +% searched without a session scope) and returns its sessions, one row of a |
| 7 | +% struct array each: |
| 8 | +% |
| 9 | +% session_id the session's id (its documents' base.session_id) |
| 10 | +% reference its local_identifier |
| 11 | +% entity_id the id of its `session` entity document |
| 12 | +% is_linked false: ingested (its documents are in DATABASE) |
| 13 | +% true: linked (they are in the folder a `linked_session` |
| 14 | +% document names) |
| 15 | +% path a linked session's folder, absolute ('' when ingested) |
| 16 | +% link_id the `linked_session` document's id ('' when ingested) |
| 17 | +% session the linked session, opened (an ndi.session.dir; [] when |
| 18 | +% ingested) |
| 19 | +% |
| 20 | +% The rule (V_eta_linked_session_plan.md, signed 2026-10-09): a session is |
| 21 | +% in the dataset when its entity document is `part_of` the dataset, or |
| 22 | +% `part_of` a study that is `part_of` the dataset; the relation is in the |
| 23 | +% dataset's database for both kinds. A session is LINKED when a |
| 24 | +% `linked_session` document names it: its `path` is the session's folder |
| 25 | +% (relative to DATASETPATH when inside it) and its `entity_id` the |
| 26 | +% session's entity document, which must be in that folder. |
| 27 | +% |
| 28 | +% NOTES is a cellstr of what was left out and why, the denominator first: |
| 29 | +% - a `session` document in DATABASE that is not part_of the dataset; |
| 30 | +% - a linked folder that is missing, cannot be opened, or holds another |
| 31 | +% session; |
| 32 | +% - a linked session that is not part_of the dataset. |
| 33 | +% When DATABASE holds no `dataset` document (an import whose metadata stage |
| 34 | +% did not run), membership cannot be read, so every session is listed and |
| 35 | +% NOTES says so. |
| 36 | +% |
| 37 | +% Options: |
| 38 | +% 'OpenLinked' default true. false lists linked sessions without opening |
| 39 | +% them (session_id and reference are then '' and |
| 40 | +% `session` is []), e.g. to find a link whose folder moved. |
| 41 | +% |
| 42 | +% See also ndi.dataset, ndi.dataset.dir. |
| 43 | + |
| 44 | +arguments |
| 45 | + database |
| 46 | + datasetSessionId (1,:) char |
| 47 | + datasetPath (1,:) char |
| 48 | + options.OpenLinked (1,1) logical = true |
| 49 | +end |
| 50 | + |
| 51 | +sessions = struct('session_id', {}, 'reference', {}, 'entity_id', {}, ... |
| 52 | + 'is_linked', {}, 'path', {}, 'link_id', {}, 'session', {}); |
| 53 | +notes = {}; |
| 54 | + |
| 55 | +% --- membership: part_of the dataset, directly or through a study --- |
| 56 | +datasetDocs = database.search(ndi.v2.isaQuery('dataset')); |
| 57 | +datasetDocs = datasetDocs(cellfun(@(d) strcmp(ndi.v2.kindOf(d.document_properties), ... |
| 58 | + 'dataset'), datasetDocs)); |
| 59 | +knowMembership = ~isempty(datasetDocs); |
| 60 | +% level 1: part_of the dataset (studies, or sessions directly); level 2: |
| 61 | +% part_of one of those (the sessions of a study) |
| 62 | +level1 = {}; |
| 63 | +for k = 1:numel(datasetDocs) |
| 64 | + level1 = [level1, partOfChildren(database, datasetDocs{k}.id())]; %#ok<AGROW> |
| 65 | +end |
| 66 | +level1 = unique(level1); |
| 67 | +members = level1; |
| 68 | +for k = 1:numel(level1) |
| 69 | + members = [members, partOfChildren(database, level1{k})]; %#ok<AGROW> |
| 70 | +end |
| 71 | +members = unique(members); |
| 72 | + |
| 73 | +% --- ingested: `session` documents in the dataset's own database --- |
| 74 | +sessionDocs = database.search(ndi.v2.isaQuery('session')); |
| 75 | +nIngested = 0; |
| 76 | +left = {}; |
| 77 | +for i = 1:numel(sessionDocs) |
| 78 | + p = sessionDocs{i}.document_properties; |
| 79 | + if ~strcmp(ndi.v2.kindOf(p), 'session') || strcmp(p.base.session_id, datasetSessionId) |
| 80 | + continue; |
| 81 | + end |
| 82 | + ref = char(ndi.v2.blockOf(p, 'session', 'local_identifier', '')); |
| 83 | + if knowMembership && ~ismember(p.base.id, members) |
| 84 | + left{end+1} = ref; %#ok<AGROW> |
| 85 | + continue; |
| 86 | + end |
| 87 | + nIngested = nIngested + 1; |
| 88 | + sessions(end+1) = struct('session_id', p.base.session_id, 'reference', ref, ... |
| 89 | + 'entity_id', p.base.id, 'is_linked', false, 'path', '', 'link_id', '', ... |
| 90 | + 'session', []); %#ok<AGROW> |
| 91 | +end |
| 92 | + |
| 93 | +% --- linked: `linked_session` documents --- |
| 94 | +links = {}; |
| 95 | +try |
| 96 | + links = database.search(ndi.query('', 'isa', 'linked_session', '')); |
| 97 | +catch |
| 98 | + % a schema without the class: no linked sessions |
| 99 | +end |
| 100 | +nLinked = 0; |
| 101 | +problems = {}; |
| 102 | +for i = 1:numel(links) |
| 103 | + p = links{i}.document_properties; |
| 104 | + rel = char(ndi.v2.blockOf(p, 'linked_session', 'path', '')); |
| 105 | + e = ndi.v2.edgeIds(p, 'entity_id'); |
| 106 | + if isempty(e) |
| 107 | + problems{end+1} = sprintf('linked_session %s names no session entity', p.base.id); %#ok<AGROW> |
| 108 | + continue; |
| 109 | + end |
| 110 | + entityId = e{1}; |
| 111 | + folder = ndi.v2.resolveLinkPath(rel, datasetPath); |
| 112 | + if knowMembership && ~ismember(entityId, members) |
| 113 | + problems{end+1} = sprintf('%s (linked) is not part_of the dataset', folder); %#ok<AGROW> |
| 114 | + continue; |
| 115 | + end |
| 116 | + row = struct('session_id', '', 'reference', '', 'entity_id', entityId, ... |
| 117 | + 'is_linked', true, 'path', folder, 'link_id', p.base.id, 'session', []); |
| 118 | + if options.OpenLinked |
| 119 | + [S, sp, why] = openLinked(folder, entityId); |
| 120 | + if isempty(S) |
| 121 | + problems{end+1} = why; %#ok<AGROW> |
| 122 | + continue; |
| 123 | + end |
| 124 | + row.session = S; |
| 125 | + row.session_id = S.id(); |
| 126 | + row.reference = char(ndi.v2.blockOf(sp, 'session', 'local_identifier', '')); |
| 127 | + end |
| 128 | + nLinked = nLinked + 1; |
| 129 | + sessions(end+1) = row; %#ok<AGROW> |
| 130 | +end |
| 131 | + |
| 132 | +notes{end+1} = sprintf(['DENOMINATOR: %d session document(s) and %d linked_session ' ... |
| 133 | + 'document(s) read; %d ingested and %d linked session(s) listed'], ... |
| 134 | + sum(cellfun(@(d) strcmp(ndi.v2.kindOf(d.document_properties), 'session') && ... |
| 135 | + ~strcmp(d.document_properties.base.session_id, datasetSessionId), sessionDocs)), ... |
| 136 | + numel(links), nIngested, nLinked); |
| 137 | +if ~knowMembership |
| 138 | + notes{end+1} = ['no dataset document: membership (part_of) cannot be read, so every ' ... |
| 139 | + 'session is listed']; |
| 140 | +end |
| 141 | +if ~isempty(left) |
| 142 | + notes{end+1} = sprintf('%d session document(s) not part_of the dataset, not listed: %s', ... |
| 143 | + numel(left), strjoin(left, ', ')); |
| 144 | +end |
| 145 | +for k = 1:numel(problems) |
| 146 | + notes{end+1} = ['not listed: ' problems{k}]; %#ok<AGROW> |
| 147 | +end |
| 148 | +end |
| 149 | + |
| 150 | +% ----------------------------------------------------------------------------- |
| 151 | +function children = partOfChildren(database, parentId) |
| 152 | +% the ids of the documents `part_of` PARENTID, read from relations in DATABASE |
| 153 | +children = {}; |
| 154 | +q = ndi.query('', 'isa', 'directed_relation', '') & ... |
| 155 | + ndi.query('', 'depends_on', 'parent_id', parentId); |
| 156 | +rels = database.search(q); |
| 157 | +for k = 1:numel(rels) |
| 158 | + p = rels{k}.document_properties; |
| 159 | + if ~strcmp(ndi.v2.termName(ndi.v2.blockOf(p, 'directed_relation', 'relation', '')), 'part_of') |
| 160 | + continue; |
| 161 | + end |
| 162 | + c = ndi.v2.edgeIds(p, 'child_id'); |
| 163 | + children = [children, c(:)']; %#ok<AGROW> |
| 164 | +end |
| 165 | +end |
| 166 | + |
| 167 | +function [S, sp, why] = openLinked(folder, entityId) |
| 168 | +% the session in FOLDER, checked to hold the session entity ENTITYID |
| 169 | +S = []; sp = []; why = ''; |
| 170 | +if ~isfolder(folder) |
| 171 | + why = sprintf('%s (linked): no such folder', folder); |
| 172 | + return; |
| 173 | +end |
| 174 | +try |
| 175 | + S0 = ndi.session.dir(folder); |
| 176 | +catch err |
| 177 | + why = sprintf('%s (linked) cannot be opened: %s', folder, err.message); |
| 178 | + return; |
| 179 | +end |
| 180 | +d = S0.database_search(ndi.query('base.id', 'exact_string', entityId, '')); |
| 181 | +if isempty(d) |
| 182 | + why = sprintf(['%s (linked) holds another session (%s): its session document %s ' ... |
| 183 | + 'is not there'], folder, S0.id(), entityId); |
| 184 | + return; |
| 185 | +end |
| 186 | +sp = d{1}.document_properties; |
| 187 | +if ~strcmp(ndi.v2.kindOf(sp), 'session') |
| 188 | + why = sprintf('%s (linked): document %s is a %s, not a session', folder, entityId, ... |
| 189 | + ndi.v2.kindOf(sp)); |
| 190 | + return; |
| 191 | +end |
| 192 | +S = S0; |
| 193 | +end |
0 commit comments