├── .gitignore ├── Module.php ├── asset ├── css │ └── nesteddatatype.css └── js │ ├── jquery-ui.min.js │ └── nesteddatatype.js ├── config ├── module.config.php └── module.ini ├── readme.md ├── src ├── Controller │ └── IndexController.php ├── DataType │ └── NestedDataType.php ├── Exception │ └── NestedDataTypeFactory.php └── Service │ └── NestedDataTypeFactory.php └── view └── nested-data-type ├── data-type └── nested-data-type.phtml └── item └── sidebar-select.phtml /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /Module.php: -------------------------------------------------------------------------------- 1 | getServiceLocator()->get('Omeka\Acl'); 22 | $acl->allow(null, 'NestedDataType\Controller\Index'); 23 | } 24 | 25 | public function attachListeners(SharedEventManagerInterface $sharedEventManager) 26 | { 27 | $sharedEventManager->attach( 28 | \Omeka\Api\Representation\ItemRepresentation::class, 29 | 'rep.resource.title', 30 | [$this, 'handleResourceTitle'] 31 | ); 32 | $sharedEventManager->attach( 33 | \Omeka\Api\Representation\ItemSetRepresentation::class, 34 | 'rep.resource.title', 35 | [$this, 'handleResourceTitle'] 36 | ); 37 | $sharedEventManager->attach( 38 | \Omeka\Api\Representation\MediaRepresentation::class, 39 | 'rep.resource.title', 40 | [$this, 'handleResourceTitle'] 41 | ); 42 | $sharedEventManager->attach( 43 | \Annotate\Api\Representation\AnnotationRepresentation::class, 44 | 'rep.resource.title', 45 | [$this, 'handleResourceTitle'] 46 | ); 47 | $sharedEventManager->attach( 48 | 'Omeka\Controller\Admin\Item', 49 | 'view.add.after', 50 | [$this, 'renderAssets'] 51 | ); 52 | $sharedEventManager->attach( 53 | 'Omeka\Controller\Admin\Item', 54 | 'view.edit.after', 55 | [$this, 'renderAssets'] 56 | ); 57 | $sharedEventManager->attach( 58 | 'Omeka\Controller\Admin\ItemSet', 59 | 'view.add.after', 60 | [$this, 'renderAssets'] 61 | ); 62 | $sharedEventManager->attach( 63 | 'Omeka\Controller\Admin\ItemSet', 64 | 'view.edit.after', 65 | [$this, 'renderAssets'] 66 | ); 67 | $sharedEventManager->attach( 68 | 'Omeka\Controller\Admin\Media', 69 | 'view.add.after', 70 | [$this, 'renderAssets'] 71 | ); 72 | $sharedEventManager->attach( 73 | 'Omeka\Controller\Admin\Media', 74 | 'view.edit.after', 75 | [$this, 'renderAssets'] 76 | ); 77 | $sharedEventManager->attach( 78 | 'Omeka\DataType\Manager', 79 | 'service.registered_names', 80 | [$this, 'addResourcesClassesServices'] 81 | ); 82 | } 83 | 84 | public function renderAssets(Event $event) 85 | { 86 | $view = $event->getTarget(); 87 | $assetUrl = $view->plugin('assetUrl'); 88 | $view->headLink() 89 | ->appendStylesheet($assetUrl('css/nesteddatatype.css', 'NestedDataType')); 90 | $view->headScript() 91 | ->appendFile($assetUrl('js/jquery-ui.min.js', 'NestedDataType')); 92 | $view->headScript() 93 | ->appendFile($assetUrl('js/nesteddatatype.js', 'NestedDataType')); 94 | } 95 | 96 | public function addResourcesClassesServices(Event $event) 97 | { 98 | $resourcesClasses = $this->getServiceLocator()->get('Omeka\ApiManager')->search('resource_classes')->getContent(); 99 | 100 | $names = $event->getParam('registered_names'); 101 | 102 | foreach ($resourcesClasses as $class) { 103 | $names[] = 'nesteddatatype#' . $class->term(); 104 | } 105 | 106 | $event->setParam('registered_names', $names); 107 | } 108 | 109 | /** 110 | * Manage the parsing of the title. 111 | * 112 | * @param Event $event 113 | */ 114 | public function handleResourceTitle(Event $event): void 115 | { 116 | $resource = $event->getTarget(); 117 | $template = $resource->resourceTemplate(); 118 | 119 | if ($template && $property = $template->titleProperty()) { 120 | $title = $resource->value($property->term()); 121 | $properties = json_decode($title, true); 122 | 123 | $event->setParam('title', (string) $title); 124 | if (isset($properties[0]['@type'])) { 125 | $values = []; 126 | 127 | foreach ($properties[0] as $key => $val) { 128 | if (is_array($val) || is_object($val)) { 129 | foreach ($val as $innerKey => $innerVal) { 130 | if ($innerVal['@value']) { 131 | $values[$key] = $innerVal['@value']; 132 | continue; 133 | } 134 | if ($innerVal['label']) { 135 | $values[$key] = $innerVal['label']; 136 | continue; 137 | } 138 | foreach ($innerVal as $secondKey => $secondVal) { 139 | $values[$key] = $secondVal['@value']; 140 | if ($secondVal['@value']) { 141 | $values[$key] = $secondVal['@value']; 142 | } 143 | if ($secondVal['label']) { 144 | $values[$key] = $secondVal['label']; 145 | } 146 | } 147 | } 148 | } 149 | } 150 | $cleanedTitle = implode('; ', $values); 151 | $event->setParam('title', (string) $cleanedTitle); 152 | } else { 153 | $event->setParam('title', (string) $title); 154 | } 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /asset/css/nesteddatatype.css: -------------------------------------------------------------------------------- 1 | .ml { 2 | margin-left: 10px; 3 | } 4 | 5 | .nested-data-type_repeat_property { 6 | background: white; 7 | position: relative; 8 | border-bottom: 1px solid rgba(0,0,0,0.35); 9 | } 10 | 11 | .nested-data-type_handle { 12 | width: 12px; 13 | display: inline; 14 | background-color:rgba(0,0,0,0.05); 15 | border-bottom: 1px solid rgba(0,0,0,0.35); 16 | align-self: stretch; 17 | border-right: 0; 18 | margin: 0; 19 | line-height: 34px; 20 | } 21 | 22 | .nested-data-type_handle::before { 23 | content: ""; 24 | font-family: "Font Awesome 5 Free"; 25 | font-weight: 900; 26 | opacity: .35; 27 | font-size: 10px; 28 | line-height: 12px; 29 | } 30 | 31 | 32 | .hidden, .hidden, .hidden .value-uri { 33 | display: none; 34 | } 35 | 36 | .nested-data-type_entity_label { 37 | padding-left: 5px; 38 | font-weight: 800; 39 | background: rgba(0,0,0,0.1); 40 | border-bottom: 1px solid rgba(0,0,0,0.10); 41 | } 42 | 43 | .nested-data-type_repeat_property option, .nested-data-type_repeat_property input { 44 | color: rgb(0 0 0 / 35%); 45 | } 46 | 47 | .nested-data-type_add_property { 48 | margin-left: 10px !important; 49 | cursor: pointer; 50 | } 51 | 52 | .inputs .nested-data-type_button { 53 | margin: unset; 54 | padding: unset; 55 | background: none; 56 | box-shadow: none; 57 | } 58 | 59 | .nested-data-type_button.nested-data-type_add_property, .nested-data-type_button.nested-data-type_add_resource { 60 | background-color: rgba(0,0,0,0.08); 61 | color: #676767; 62 | border-radius: 3px; 63 | border: 0; 64 | box-shadow: 0 0 0 1px rgb(0 0 0 / 15%) inset; 65 | padding: 6px 10px; 66 | display: inline-block; 67 | text-align: center; 68 | cursor: pointer; 69 | font-size: 14px; 70 | line-height: 12px; 71 | min-height: 0px; 72 | margin-bottom: 12px; 73 | } 74 | 75 | .nested-data-type_add_property[class*="o-icon-"]::before { 76 | padding-right: 5px; 77 | } 78 | 79 | .o-icon-delete.nested-data-type_remove_property, .nested-data-type_hide_property { 80 | position: absolute; 81 | display: block; 82 | right: -25px; 83 | /* top: 50%; */ 84 | top: 60px; 85 | width: fit-content; 86 | color: #cdcdcd; 87 | cursor: pointer; 88 | } 89 | 90 | .nested-data-type_hide_property { 91 | /* top: 20%; */ 92 | top: 30px; 93 | right: -27px; 94 | } 95 | 96 | .nested-data-type_repeat_class { 97 | margin-left: 1px; 98 | } 99 | 100 | .nested-data-type_repeat_class input { 101 | width: calc(50% - 2px); 102 | background: #FFF8E5; 103 | } 104 | 105 | .inputs .nested-data-type_repeat_property datalist { 106 | margin: 0; 107 | border-radius: 0; 108 | } 109 | 110 | .nested-data-type_repeat_property_list { 111 | display: inline-flex; 112 | width: 100%; 113 | } 114 | 115 | .nested-data-type-dropwdown { 116 | padding-left: 6px; 117 | width: calc(100% - 30px); 118 | background: none; 119 | border: none; 120 | border-bottom: 1px solid rgba(0,0,0,0.35); 121 | background: rgba(0,0,0,0.05); 122 | height: 36px; 123 | } 124 | 125 | .hidden { 126 | display: none; 127 | } 128 | 129 | .inputs .nested-data-type_add_class { 130 | font-size: .75rem; 131 | height: fit-content; 132 | border-radius: 0; 133 | width: 30px; 134 | background: rgba(0,0,0,0.05); 135 | border-bottom: 1px solid rgba(0,0,0,0.35); 136 | vertical-align: bottom; 137 | } 138 | 139 | .nested-data-type_property_dropdown::-webkit-calendar-picker-indicator { 140 | opacity: 100; 141 | } 142 | 143 | .nested-data-type_properties .input ~ .input { 144 | border-bottom: unset; 145 | } 146 | 147 | .nested-data-type_properties .input label { 148 | z-index: 1; 149 | display: flex; 150 | /* padding: 6px 0 6px 6px; */ 151 | width: auto; 152 | font-weight: bold; 153 | color: rgba(0,0,0,0.35); 154 | } 155 | 156 | .nested-data-type_properties .input textarea { 157 | margin: -6px 0 -6px 0; 158 | /* height: 36px; */ 159 | /* color: #222; */ 160 | border: 0; 161 | padding: 6px; 162 | font-weight: normal; 163 | background: transparent; 164 | min-height: 54px !important; 165 | } 166 | 167 | .o-title.items { 168 | line-height: 1.6rem; 169 | min-height: 54px !important; 170 | } 171 | 172 | .nested-data-type_properties .items a:after { 173 | font-family: "Font Awesome 5 Free"; 174 | content: " \f1b2"; 175 | } 176 | -------------------------------------------------------------------------------- /asset/js/nesteddatatype.js: -------------------------------------------------------------------------------- 1 | $(document).on('o:prepare-value', function (e, type, value, valueObj) { 2 | const thisValue = $(value); 3 | const container = thisValue.find('.nested-data-type_properties'); 4 | container.sortable() 5 | const addBtn = thisValue.find('.nested-data-type_add_property'); 6 | 7 | // Add a default Value to trigger the hydrate() function 8 | const defaultValue = thisValue.find('.nested-data-type_value').val('value'); 9 | const defaultProperty = thisValue.find('.nested-data-type_property').val('value'); 10 | 11 | let repeatProperty, select, textareaValue, textareaUri; 12 | const findItems = () => { 13 | repeatProperty = container.find('.nested-data-type_repeat_property').last(); 14 | isHidden = repeatProperty.find('.nested-data-type_is-hidden'); 15 | select = repeatProperty.find('.nested-data-type_property_dropdown'); 16 | textareaValue = repeatProperty.find('.property-value'); 17 | textareaUri = repeatProperty.find('.property-uri'); 18 | innerClass = repeatProperty.find('.inner-class'); 19 | innerProperty = repeatProperty.find('.inner-property'); 20 | renderedLink = repeatProperty.find('.items'); 21 | } 22 | 23 | const cloneItem = (idx) => { 24 | let item = ` 25 |
26 |
27 |
28 | 29 | 30 |
31 | 35 |
36 | 39 |
40 | 45 | 46 | 47 | 48 |
`; 49 | 50 | container.append(container.append(item)); 51 | } 52 | 53 | const structureField = (obj, type, insertVal = '') => { 54 | return obj.attr({ 'data-value-key': `${type}` }).val(insertVal); 55 | } 56 | 57 | const structureInnerLinks = (insertVal, url) => { 58 | let title; 59 | 60 | $.ajaxSetup({ 61 | async: false 62 | }); 63 | $.getJSON(url.replace("/admin/item/", "/api/items/"), function (data) { 64 | title = data["o:title"]; 65 | }); 66 | 67 | let link = `
${title || insertVal}
` 68 | container.append(container.find('.nested-data-type_repeat_property').last().append(link)); 69 | } 70 | 71 | // Replace item on click 72 | // container.on('click', '.re-link', function (e) { 73 | // e.preventDefault(); 74 | // openSidebar() 75 | // }); 76 | 77 | // Add item on click 78 | addBtn.on('click', function (e) { 79 | e.preventDefault(); 80 | const num = container.find('.nested-data-type_repeat_property').length; 81 | cloneItem(num + 1); 82 | findItems(); 83 | 84 | if (renderedLink) { 85 | renderedLink.remove(); 86 | textareaValue.parent().parent().css('display', 'block'); 87 | } 88 | 89 | container.find('.nested-data-type_repeat_property') 90 | .last() 91 | .find('.o-icon-private') 92 | .removeClass('o-icon-private') 93 | .addClass('o-icon-public')(); 94 | }); 95 | 96 | // Remove Button on click 97 | container.on('click', '.nested-data-type_remove_property', function (e) { 98 | e.preventDefault(); 99 | const nextItems = $(this).parent().nextAll(); 100 | $(this).parent().remove(); 101 | 102 | // change the index number 103 | for (let index = 0; index < nextItems.length; index++) { 104 | const element = $(nextItems[index]).find("input[data-value-key]"); 105 | for (let item = 0; item < element.length; item++) { 106 | const dataValueKey = $(element[item]).attr('data-value-key').split('-'); 107 | const updatedIndex = dataValueKey.join('-') + "-" + (dataValueKey.pop() - 1); 108 | structureField($(element[item]), updatedIndex, insertVal = element[item].value) 109 | } 110 | } 111 | }); 112 | 113 | // Show properties on click 114 | container.on('click', '.nested-data-type_hide_property', function (e) { 115 | e.preventDefault(); 116 | const isHiddenInput = $(this).parent().find('.nested-data-type_is-hidden'); 117 | const dataKey = isHiddenInput.attr('data-value-key'); 118 | const hide = isHiddenInput.attr({ 'data-value-key': dataKey }).val(); 119 | 120 | if (hide != "true") { 121 | $(this).removeClass('o-icon-public').addClass('o-icon-private'); 122 | isHiddenInput.attr({ 'data-value-key': dataKey }).val("true"); 123 | } 124 | else { 125 | $(this).removeClass('o-icon-private').addClass('o-icon-public'); 126 | isHiddenInput.attr({ 'data-value-key': dataKey }).val(""); 127 | } 128 | }); 129 | 130 | // Add Class on click 131 | container.on('click', '.nested-data-type_add_class', function (e) { 132 | e.preventDefault(); 133 | $(this).parent().next().children().val('') 134 | $(this).parent().next().toggle(); 135 | }); 136 | 137 | 138 | // Prepares the fields to be rendered in the frontend 139 | if (0 === type.indexOf('nesteddatatype#')) { 140 | if (valueObj != undefined) { 141 | const p = valueObj["@value"]; 142 | const k = Object.keys(p[0]); 143 | renderFields(p, k) 144 | } 145 | 146 | else { 147 | let templateId = $('#resource-template-select').val(); 148 | let templateUrl = `/api/resource_templates/${templateId}` 149 | getTemplateJson(templateUrl).then(function (returndata) { 150 | returndata["o:resource_template_property"].forEach(element => { 151 | if (element["o:data_type"] == type) { 152 | let p = JSON.parse(element["o:data"][0]["default_value"]); 153 | let k = Object.keys(p[0]); 154 | renderFields(p, k) 155 | } 156 | }); 157 | }); 158 | } 159 | } 160 | 161 | $(document).on('click', '.nested-data-type__resource_link', function (e) { 162 | e.preventDefault(); 163 | 164 | const resource = JSON.parse($(this.parentElement).attr('data-resource-values')); 165 | const id = resource['@id']; 166 | const label = resource['display_title']; 167 | const url = resource['url']; 168 | 169 | if (thisValue.is('.selecting-resource')) { 170 | const num = container.find('.nested-data-type_repeat_property').length; 171 | 172 | cloneItem(num + 1); 173 | findItems(); 174 | 175 | if (renderedLink) { 176 | renderedLink.remove(); 177 | textareaValue.parent().parent().css('display', 'block'); 178 | } 179 | 180 | structureField(textareaValue, `property-value-${num + 1}`, insertVal = label); 181 | structureField(textareaUri, `property-uri-${num + 1}`, insertVal = id); 182 | 183 | container.find('.nested-data-type_repeat_property') 184 | .last() 185 | .find('.input') 186 | .css('display', 'none'); 187 | 188 | container.find('.nested-data-type_repeat_property') 189 | .last() 190 | .find('.o-title.items') 191 | .remove(); 192 | 193 | container.find('.nested-data-type_repeat_property') 194 | .last() 195 | .find('.o-icon-private') 196 | .removeClass('o-icon-private') 197 | .addClass('o-icon-public') 198 | 199 | structureInnerLinks(label, url); 200 | }; 201 | }); 202 | 203 | function renderFields(properties, keys) { 204 | keys.forEach((element, idx) => { 205 | let item = properties[0][element]; 206 | 207 | for (i in item) { 208 | let val = item[i]; 209 | if (typeof val === "object") { 210 | 211 | if (idx == 1) { 212 | findItems(); 213 | 214 | // select.val(element); 215 | structureField(select, `property-label-${idx}`, insertVal = element); 216 | 217 | if (val['@value']) textareaValue.val(val['@value']); 218 | if (val['label']) textareaValue.val(val['label']); 219 | if (val['@id']) textareaUri.val(val['@id']); 220 | if (val['is_hidden']) { 221 | structureField(isHidden, `is-hidden-${idx}`, insertVal = 'true'); 222 | container.find('.nested-data-type_hide_property').last().removeClass('o-icon-public').addClass('o-icon-private') 223 | }; 224 | if (val['@id'] && val['@id'].includes('/api/items/')) { 225 | 226 | container.find('.nested-data-type_repeat_property') 227 | .last() 228 | .find('.input') 229 | .css('display', 'none'); 230 | 231 | structureInnerLinks(val['label'], val['@id'].replace('/api/items/', '/admin/item/')); 232 | } 233 | } 234 | 235 | else if (idx > 1) { 236 | cloneItem(idx); 237 | findItems(); 238 | 239 | container.find('.nested-data-type_hide_property').last().removeClass('o-icon-private').addClass('o-icon-public'); 240 | 241 | if (renderedLink) { 242 | renderedLink.remove(); 243 | textareaValue.parent().parent().css('display', 'block'); 244 | } 245 | 246 | structureField(select, `property-label-${idx}`, insertVal = element); 247 | innerClass.parent().css('display', 'none'); 248 | if (val['is_hidden']) { 249 | structureField(isHidden, `is-hidden-${idx}`, insertVal = 'true'); 250 | container.find('.nested-data-type_hide_property').last().removeClass('o-icon-public').addClass('o-icon-private') 251 | }; 252 | if (val['@value']) { structureField(textareaValue, `property-value-${idx}`, insertVal = val['@value']); }; 253 | if (val['label']) { structureField(textareaValue, `property-value-${idx}`, insertVal = val['label']); } 254 | if (val['@id']) { structureField(textareaUri, `property-uri-${idx}`, insertVal = val['@id']); } 255 | if (val['@id'] && val['@id'].includes('/api/items/')) { 256 | container.find('.nested-data-type_repeat_property') 257 | .last() 258 | .find('.o-title.items') 259 | .remove(); 260 | 261 | container.find('.nested-data-type_repeat_property') 262 | .last() 263 | .find('.input') 264 | .css('display', 'none'); 265 | 266 | structureInnerLinks(val['label'], val['@id'].replace('/api/items/', '/admin/item/')); 267 | } 268 | } 269 | 270 | for (const [key, value] of Object.entries(val)) { 271 | if (key == '@type') { innerClass.val(value).parent().css('display', 'block') }; 272 | if (idx == 1) { 273 | if (val[key]['@value']) { 274 | innerProperty.val(key); 275 | innerProperty.parent().css('display', 'block'); 276 | textareaValue.val(val[key]['@value']); 277 | } 278 | if (val[key]['@id']) { 279 | innerProperty.val(key); 280 | textareaValue.val(val[key]['@id']); 281 | } 282 | if (val[key]['label']) textareaValue.val(val[key]['label']); 283 | 284 | if (val[key]['@id'] && val[key]['@id'].includes('/api/items/')) { 285 | container.find('.nested-data-type_repeat_property') 286 | .last() 287 | .find('.o-title.items') 288 | .remove(); 289 | 290 | container.find('.nested-data-type_repeat_property') 291 | .last() 292 | .find('.input') 293 | .css('display', 'none'); 294 | structureInnerLinks(val[key]['label'], val[key]['@id'].replace('/api/items/', '/admin/item/')); 295 | } 296 | } 297 | else if (idx > 1 && key != "is_hidden") { 298 | structureField(innerProperty, `inner-property-${idx}`, insertVal = key); 299 | if (key == '@type') { structureField(innerClass, `inner-class-${idx}`, insertVal = value); }; 300 | if (val[key]['@value']) { structureField(textareaValue, `property-value-${idx}`, insertVal = val[key]['@value']); } 301 | if (val[key]['label']) { structureField(textareaValue, `property-value-${idx}`, insertVal = val[key]['label']); } 302 | if (val[key]['@id']) { structureField(textareaUri, `property-uri-${idx}`, insertVal = val[key]['@id']); }; 303 | 304 | if (val[key]['@id'] && val[key]['@id'].includes('/api/items/')) { 305 | container.find('.nested-data-type_repeat_property') 306 | .last() 307 | .find('.o-title.items') 308 | .remove(); 309 | 310 | container.find('.nested-data-type_repeat_property') 311 | .last() 312 | .find('.input') 313 | .css('display', 'none'); 314 | 315 | structureInnerLinks(val[key]['label'], val[key]['@id'].replace('/api/items/', '/admin/item/')); 316 | } 317 | } 318 | } 319 | } 320 | } 321 | }); 322 | 323 | } 324 | }); 325 | 326 | 327 | function getTemplateJson(url) { 328 | return $.getJSON(url, function (data) { 329 | return data 330 | }); 331 | } 332 | 333 | // function openSidebar() { 334 | // let sidebar = $(".sidebar"); 335 | // sidebar.addClass('active'); 336 | // sidebar.trigger('o:sidebar-opened'); 337 | // if ($('.active.sidebar').length > 1) { 338 | // var highestIndex = 3; // The CSS currently defines the default sidebar z-index as 3. 339 | // $('.active.sidebar').each(function () { 340 | // var currentIndex = parseInt($(this).css('zIndex'), 10); 341 | // if (currentIndex > highestIndex) { 342 | // highestIndex = currentIndex; 343 | // } 344 | // }); 345 | // sidebar.css('zIndex', highestIndex + 1); 346 | 347 | // // populate 348 | // var sidebarContent = sidebar.find('.sidebar-content'); 349 | // sidebar.addClass('loading'); 350 | // sidebarContent.empty(); 351 | 352 | // $.get("/admin/nested-data-type/sidebar-select") 353 | // .done(function (data) { 354 | // sidebarContent.html(data); 355 | // $(sidebar).trigger('o:sidebar-content-loaded'); 356 | // }) 357 | // .fail(function () { 358 | // sidebarContent.html('

' + Omeka.jsTranslate('Something went wrong') + '

'); 359 | // }) 360 | // .always(function () { 361 | // sidebar.removeClass('loading'); 362 | // }); 363 | // } 364 | // } 365 | -------------------------------------------------------------------------------- /config/module.config.php: -------------------------------------------------------------------------------- 1 | [ 4 | 'abstract_factories' => ['NestedDataType\Service\NestedDataTypeFactory'], 5 | ], 6 | 'controllers' => [ 7 | 'invokables' => [ 8 | 'NestedDataType\Controller\Index' => 'NestedDataType\Controller\IndexController', 9 | ], 10 | ], 11 | 'view_manager' => [ 12 | 'template_path_stack' => [ 13 | OMEKA_PATH . '/modules/NestedDataType/view', 14 | ], 15 | ], 16 | 'router' => [ 17 | 'routes' => [ 18 | 'admin' => [ 19 | 'child_routes' => [ 20 | 'nested-data-type' => [ 21 | 'type' => 'Literal', 22 | 'options' => [ 23 | 'route' => '/nested-data-type/sidebar-select', 24 | 'defaults' => [ 25 | '__NAMESPACE__' => 'NestedDataType\Controller', 26 | 'controller' => 'Index', 27 | 'action' => 'sidebar-select', 28 | ], 29 | ], 30 | 'may_terminate' => true, 31 | ], 32 | ], 33 | ], 34 | ], 35 | ], 36 | ]; 37 | -------------------------------------------------------------------------------- /config/module.ini: -------------------------------------------------------------------------------- 1 | [info] 2 | name = "Nested Data Types" 3 | description= "Allow users to choose a specific resource class as datatype, and inner properties." 4 | author = "Giacomo Nanni" 5 | author_link = "https://github.com/sinanatra" 6 | version = "4.0.0" 7 | omeka_version_constraint = "^4.0.0" 8 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Nested Data Type module for Omeka S 2 | 3 | This module allows the user to choose a specific resource class as datatype while editing resource templates. Additionally, when editing an item, the user can select inner properties for the chosen class. 4 | 5 | It was initially designed to work with the nested CIDOC-CRM structure in Omeka S, avoiding creating an abundance of Omeka resources. 6 | However, it should only replace an Omeka resource, literal, or URI when strictly necessary. 7 | 8 | For instance: 9 | 10 | - crm:P43_has_dimension 11 | - crm:E54_Dimension 12 | - crm:P2_has_type 13 | - crm:P90_has_value 14 | - crm:P91_has_unit 15 | 16 | Can be structured as a multi input field: 17 | 18 | ![alt text](https://gist.githubusercontent.com/sinanatra/a39c3625f3871c19a7e720d3ceb44339/raw/5ffa98b47e96a9225ed4d80340684d8036c67e89/img.png) 19 | 20 | The module fills the content of `@value` as json-ld based on the values from the multi input field. 21 | Specific keys can be ignored when rendering the `@value` with the `"is_hidden"` property, which can be activated by clicking on the eye icon. 22 | 23 | ```json 24 | { 25 | "type": "nesteddatatype#crm:E54_Dimension", 26 | "property_id": 1262, 27 | "property_label": "P43 has dimension", 28 | "is_public": true, 29 | "@value": [ 30 | { 31 | "@type": "crm:E54_Dimension", 32 | "crm:P2_has_type": [ 33 | { 34 | "@id": "http://localhost:8080/api/items/23685", 35 | "label": "width" 36 | } 37 | ], 38 | "crm:P90_has_value": [ 39 | { 40 | "@value": "90" 41 | } 42 | ], 43 | "crm:P91_has_unit": [ 44 | { 45 | "@id": "http://localhost:8080/api/items/23686", 46 | "label": "centimeters" 47 | } 48 | ] 49 | } 50 | ] 51 | } 52 | ``` 53 | 54 | The content of `@value` is structured as JSON-LD that can be easily converted to other semantic web standars: 55 | 56 | ### RDFXML: 57 | ```xml 58 | 59 | 60 | 61 | 90 62 | 63 | 64 | 65 | ``` 66 | ### Turtle: 67 | ``` 68 | @prefix ns0: . 69 | @prefix xsd: . 70 | 71 | [] ns0:P43_has_dimension [ 72 | a ns0:E54_Dimension ; 73 | ns0:P2_has_type ; 74 | ns0:P90_has_value "90"^^xsd:string ; 75 | ns0:P91_has_unit 76 | ] . 77 | ``` 78 | 79 | ### N-Triples: 80 | ``` 81 | _:genid1 _:genid2 . 82 | _:genid2 . 83 | _:genid2 . 84 | _:genid2 "90"^^ . 85 | _:genid2 . 86 | ``` 87 | 88 | ## Installation 89 | 90 | * See general end user documentation for [Installing a module](http://omeka.org/s/docs/user-manual/modules/#installing-modules) 91 | 92 | ## Usage 93 | 94 | * Activate the NestedDataType module. 95 | * On the resource template, you can choose a resource class from previously installed vocabularies as datatype. 96 | * When you create or edit an item based on the template, the sidebar suggest you the specific class choose above. 97 | * A dropdown will help you in chosing an inner property to insert. 98 | 99 | ## Contribute 100 | 101 | Feel free to submit any issue or request. 102 | 103 | This project is still in development and I use it for my own projects. Don't use it on a production website if you're not sure of being able to correct my bugs. 104 | -------------------------------------------------------------------------------- /src/Controller/IndexController.php: -------------------------------------------------------------------------------- 1 | setBrowseDefaults('created'); 13 | 14 | $response = $this->api()->search('items', $this->params()->fromQuery()); 15 | $this->paginator($response->getTotalResults()); 16 | 17 | $view = new ViewModel; 18 | $view->setVariable('items', $response->getContent()); 19 | $view->setVariable('search', $this->params()->fromQuery('search')); 20 | $view->setVariable('resourceClassId', $this->params()->fromQuery('resource_class_id')); 21 | $view->setVariable('itemSetId', $this->params()->fromQuery('item_set_id')); 22 | $view->setVariable('showDetails', false); 23 | $view->setTerminal(true); 24 | $view->setTemplate('/nested-data-type/item/sidebar-select'); 25 | return $view; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/DataType/NestedDataType.php: -------------------------------------------------------------------------------- 1 | resourceClass = $resourceClass; 30 | $this->properties = $properties; 31 | $this->classes = $classes; 32 | } 33 | 34 | public function getName() 35 | { 36 | return 'nesteddatatype#'.$this->resourceClass->term(); 37 | } 38 | 39 | public function getLabel() 40 | { 41 | return $this->resourceClass->term(); 42 | } 43 | 44 | public function getOptgroupLabel() 45 | { 46 | return 'Nested Class'; // @translate 47 | } 48 | 49 | public function form(PhpRenderer $view) 50 | { 51 | return $view->partial('nested-data-type/data-type/nested-data-type', [ 52 | 'dataType' => $this->getName(), 53 | 'label' => $this->getLabel(), 54 | 'properties' => $this->properties, 55 | 'classes' => $this->classes, 56 | 'resource' => $view->resource, 57 | ]); 58 | } 59 | 60 | public function getJsonLd(ValueRepresentation $value) 61 | { 62 | $values= json_decode($value->value(), true); 63 | $simpleValue = []; 64 | 65 | foreach ($values[0] as $key => $val) { 66 | if (is_array($val) || is_object($val)){ 67 | foreach ($val as $innerKey => $innerVal) { 68 | if(!isset($innerVal['is_hidden'])){ 69 | if(isset($innerVal['@value'])){ 70 | $simpleValue[$key] = $innerVal['@value']; 71 | continue; 72 | } 73 | if(isset($innerVal['label'])){ 74 | $simpleValue[$key] = $innerVal['label']; 75 | continue; 76 | } 77 | if (is_array($innerVal) || is_object($innerVal)){ 78 | foreach ($innerVal as $secondKey => $secondVal) { 79 | if(isset($secondVal['@value'])){ 80 | $simpleValue[$key] = $secondVal['@value']; 81 | } 82 | if(isset($secondVal['label'])){ 83 | $simpleValue[$key] = $secondVal['label']; 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | $jsonLd = [ 93 | '@value' => $values, 94 | 'simpleValue' => implode('; ', $simpleValue), 95 | ]; 96 | 97 | return $jsonLd; 98 | } 99 | 100 | /** 101 | * @param array $valueObject 102 | */ 103 | public function isValid(array $valueObject){ 104 | 105 | // $labels = array_map( 106 | // function ($prop){ 107 | // return $prop->term(); 108 | // }, 109 | // $this->properties 110 | // ); 111 | 112 | // foreach($valueObject as $key => $label) { 113 | // if (strpos($key, 'property-label') !== false) { 114 | // if(!in_array($label, $labels)){ 115 | // return false; 116 | // } 117 | // } 118 | // } 119 | 120 | return true; 121 | } 122 | 123 | public function hydrate(array $valueObject, Value $value, AbstractEntityAdapter $adapter){ 124 | $serviceLocator = $adapter->getServiceLocator(); 125 | 126 | $prevLabel = ''; 127 | $num = 0; 128 | 129 | if(strpos($valueObject['@value'],'@type') !== false){ 130 | $value->setValue(json_encode($valueObject['@value'])); 131 | } 132 | 133 | 134 | else { 135 | $properties = []; 136 | 137 | $properties = array_merge( 138 | ["@type" => $this->getLabel() ] 139 | ); 140 | 141 | 142 | foreach($valueObject as $key => $label) { 143 | 144 | if ($prevLabel == $label) { 145 | $num += 1; 146 | } 147 | 148 | if (substr($key,0,15) !== 'property-label-'){ 149 | continue; 150 | } 151 | 152 | $idx = (int) substr($key,15); 153 | $val = $valueObject["property-value-$idx"]; 154 | $uri = $valueObject["property-uri-$idx"]; 155 | 156 | // Update title from Omeka Id - to be cleaned. 157 | if (strpos($uri, '/api/items/') !== false) { 158 | try { 159 | $str = explode("/api/items/", $uri); 160 | $api = $serviceLocator->get('Omeka\ApiManager'); 161 | $response = $api->read('items', $str[1]); 162 | $val = $response->getContent()->displayTitle(); 163 | } catch (\Throwable $th) {} 164 | } 165 | 166 | $innerClass = $valueObject["inner-class-$idx"]; 167 | $innerProp = $valueObject["inner-property-$idx"]; 168 | $isHidden = $valueObject["is-hidden-$idx"]; 169 | 170 | $prevLabel = $label; 171 | 172 | if ($innerClass && $innerProp){ 173 | $properties[$label][$num] = array_merge( 174 | ["@type" => $innerClass ], 175 | [$innerProp => $uri ? ['@id' => $uri, 'label' => $val] : ['@value' => $val] ], 176 | $isHidden ? ['is_hidden' => $isHidden] : [] 177 | ); 178 | } else { 179 | $properties[$label][$num] = array_merge( 180 | $uri ? ['@id' => $uri, 'label' => $val] : ['@value' => $val], 181 | $isHidden ? ['is_hidden' => $isHidden] : [] 182 | ); 183 | } 184 | } 185 | 186 | $value->setValue(json_encode([$properties])); 187 | $prevLabel .= $label; 188 | } 189 | } 190 | 191 | public function render(PhpRenderer $view, ValueRepresentation $value){ 192 | 193 | $values = json_decode($value->value(), true); 194 | $simpleValue = []; 195 | 196 | foreach ($values[0] as $key => $val) { 197 | 198 | if($key == '@type'){ 199 | $simpleValue[$key] = $val; 200 | } 201 | 202 | if (is_array($val) || is_object($val)){ 203 | foreach ($val as $innerKey => $innerVal) { 204 | if(!isset($innerVal['is_hidden'])){ 205 | if(isset($innerVal['@value'])){ 206 | $simpleValue[$key] = $innerVal['@value']; 207 | continue; 208 | } 209 | if(isset($innerVal['label'])){ 210 | $simpleValue[$key] = $innerVal; 211 | continue; 212 | } 213 | if (is_array($innerVal) || is_object($innerVal)){ 214 | foreach ($innerVal as $secondKey => $secondVal) { 215 | if(isset($secondVal['@value'])){ 216 | $simpleValue[$key] = $secondVal['@value']; 217 | } 218 | if(isset($secondVal['label'])){ 219 | $simpleValue[$key] = $secondVal; 220 | } 221 | } 222 | } 223 | } 224 | } 225 | } 226 | } 227 | 228 | // return json_encode($values); 229 | return "
" . implode('', array_map( 230 | function ($v, $k) { 231 | 232 | if($k == '@type'){ 233 | return "
" . str_replace("_", " ", explode(":", $v)[1]) . "
"; 234 | } 235 | 236 | if(isset($v['label'])){ 237 | $url = explode("items/", $v['@id'])[1]; 238 | $v = "" . $v['label']. ""; 239 | } 240 | else { 241 | $v = "" . $v . ""; 242 | } 243 | 244 | $k = "" . str_replace("_", " ", explode(":", $k)[1]) . ""; 245 | 246 | return "
" . $k . "" .": " . "" . $v . "
"; 247 | }, 248 | $simpleValue, 249 | array_keys($simpleValue) 250 | )). "
"; 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/Exception/NestedDataTypeFactory.php: -------------------------------------------------------------------------------- 1 | get('Omeka\ApiManager'); 24 | $resourceClasses = $apiManager->search('resource_classes', ['term' => $term])->getContent(); 25 | 26 | $vocabularyId = $resourceClasses[0]->vocabulary()->id(); 27 | $properties = $apiManager->search('properties', ['vocabulary_id' => $vocabularyId])->getContent(); 28 | $classes = $apiManager->search('resource_classes', ['vocabulary_id' => $vocabularyId])->getContent(); 29 | 30 | $i = new NestedDataType($resourceClasses[0], $properties, $classes); 31 | 32 | return $i; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /view/nested-data-type/data-type/nested-data-type.phtml: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 | 19 | 20 |
21 | 25 |
26 | 29 |
30 | 35 | 36 | 37 | 38 |
39 |
40 | 41 | 42 | 43 | 44 | Link Item 45 | 46 | -------------------------------------------------------------------------------- /view/nested-data-type/item/sidebar-select.phtml: -------------------------------------------------------------------------------- 1 | plugin('translate'); 3 | $escape = $this->plugin('escapeHtml'); 4 | $hyperlink = $this->plugin('hyperlink'); 5 | $itemsFound = count($items) > 0; 6 | $expanded = $resourceClassId || $itemSetId || $id; 7 | ?> 8 | 9 |
10 |

11 | 12 |
13 | 58 | 59 | pagination('common/sidebar-pagination.phtml'); ?> 60 | 61 |
62 | 63 | 64 | 65 | 66 |
67 | 68 |
69 | 72 | %s', 75 | $this->thumbnail($item, 'square'), 76 | $escape($item->displayTitle()) 77 | ); 78 | $attrs = [ 79 | 'class' => 'select-resource resource-link nested-data-type__resource_link', 80 | ]; 81 | echo $hyperlink->raw($content, '#', $attrs); 82 | ?> 83 |
84 | 85 |
86 | 87 | 88 | 89 |
90 | 91 | 92 |
93 | 94 |
95 | --------------------------------------------------------------------------------