Spaces:
Paused
Paused
| """ | |
| Multirate Layout | |
| Generates a matrix-style interface for rating multiple items on the same scale. | |
| Features include: | |
| - Multiple column layout support | |
| - Configurable rating options | |
| - Vertical/horizontal arrangement options | |
| - Tooltip support | |
| - Required/optional validation | |
| """ | |
| import logging | |
| import os | |
| from collections.abc import Mapping | |
| from jinja2 import Template | |
| from .identifier_utils import ( | |
| safe_generate_layout, | |
| generate_element_identifier, | |
| generate_validation_attribute, | |
| escape_html_content, | |
| generate_layout_attributes | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # HTML template using Jinja2 with comprehensive styling that preserves horizontal layout | |
| MULTIRATE_TEMPLATE = """ | |
| <form id="{{ schema_name }}" class="annotation-form multirate shadcn-multirate-container" action="javascript:void(0)" data-annotation-id="{{ annotation_id }}" {{ layout_attrs }}> | |
| <fieldset schema="{{ schema_name }}"> | |
| <legend class="shadcn-multirate-title">{{ description }}</legend> | |
| <table class="shadcn-multirate-table"> | |
| <thead> | |
| <tr> | |
| {% for col in range(num_headers) %} | |
| <th> </th> | |
| {% for rating in ratings %} | |
| <th>{{ rating }}</th> | |
| {% endfor %} | |
| {% endfor %} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for row in rows %} | |
| <tr schema="multirate"> | |
| {% for item in row %} | |
| {% if item %} | |
| <td {{ item.tooltip|safe }}>{{ item.label }}</td> | |
| {% for rating in ratings %} | |
| <td class="shadcn-radio-cell"> | |
| <input name="{{ item.name }}" | |
| type="radio" | |
| id="{{ item.id }}.{{ rating }}" | |
| value="{{ rating }}" | |
| onclick="this.blur();" | |
| validation="{{ validation }}" | |
| class="shadcn-multirate-radio annotation-input" | |
| schema="{{ schema_name }}" | |
| label_name="{{ item.label_name }}" | |
| aria-label="{{ item.label }}: {{ rating }}" /> | |
| </td> | |
| {% endfor %} | |
| {% else %} | |
| <td></td> | |
| {% for rating in ratings %} | |
| <td></td> | |
| {% endfor %} | |
| {% endif %} | |
| {% endfor %} | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </fieldset> | |
| </form> | |
| """ | |
| def generate_multirate_layout(annotation_scheme): | |
| """ | |
| Generate HTML for a multi-item rating interface. | |
| Args: | |
| annotation_scheme (dict): Configuration including: | |
| - name: Schema identifier | |
| - description: Display description | |
| - options: List of items to be rated | |
| - labels: List of rating options to choose from | |
| - display_config (dict): Optional display settings | |
| - arrangement (str): Layout direction ('vertical' or 'horizontal') | |
| - label_requirement (dict): Optional validation settings | |
| Returns: | |
| tuple: (html_string, key_bindings) | |
| html_string: Complete HTML for the multirate interface | |
| key_bindings: List of (key, description) tuples for keyboard shortcuts | |
| """ | |
| return safe_generate_layout(annotation_scheme, _generate_multirate_layout_internal) | |
| def _generate_multirate_layout_internal(annotation_scheme): | |
| """ | |
| Internal function to generate multirate layout after validation. | |
| """ | |
| logger.debug(f"Generating multirate layout for schema: {annotation_scheme['name']}") | |
| # Check for options_from_data (dynamic multirate) | |
| options_from_data = annotation_scheme.get('options_from_data') | |
| if options_from_data and 'options' not in annotation_scheme: | |
| return _generate_dynamic_multirate(annotation_scheme, options_from_data) | |
| # Extract configuration | |
| schema_name = annotation_scheme['name'] | |
| description = annotation_scheme['description'] | |
| options = annotation_scheme['options'] | |
| ratings = annotation_scheme['labels'] | |
| # Get display configuration | |
| display_config = annotation_scheme.get('display_config', {}) | |
| num_columns = display_config.get('num_columns', 1) | |
| # Set validation | |
| validation = generate_validation_attribute(annotation_scheme) | |
| # Get layout attributes for grid positioning | |
| layout_attrs = generate_layout_attributes(annotation_scheme) | |
| # Preprocess items for template | |
| processed_items = [] | |
| for option in options: | |
| if isinstance(option, str): | |
| identifiers = generate_element_identifier(schema_name, option, "multirate") | |
| processed_items.append({ | |
| 'label': escape_html_content(option), | |
| 'name': identifiers['name'], | |
| 'id': identifiers['id'], | |
| 'label_name': identifiers['label_name'], | |
| 'tooltip': "" | |
| }) | |
| else: | |
| identifiers = generate_element_identifier(schema_name, option['name'], "multirate") | |
| processed_items.append({ | |
| 'label': escape_html_content(option['label']), | |
| 'name': identifiers['name'], | |
| 'id': identifiers['id'], | |
| 'label_name': identifiers['label_name'], | |
| 'tooltip': _generate_tooltip(option) | |
| }) | |
| # Arrange items according to specified layout | |
| if annotation_scheme.get('arrangement') == 'vertical': | |
| arranged_items = _arrange_items_vertically(processed_items, num_columns) | |
| else: | |
| arranged_items = _arrange_items_horizontally(processed_items, num_columns) | |
| # Format template data | |
| template_data = { | |
| 'schema_name': escape_html_content(schema_name), | |
| 'description': escape_html_content(description), | |
| 'ratings': [escape_html_content(rating) for rating in ratings], | |
| 'num_headers': min(len(options), num_columns), | |
| 'rows': arranged_items, | |
| 'validation': validation, | |
| 'annotation_id': annotation_scheme.get('annotation_id', ''), | |
| 'layout_attrs': layout_attrs | |
| } | |
| # Render template | |
| template = Template(MULTIRATE_TEMPLATE) | |
| html = template.render(**template_data) | |
| logger.info(f"Successfully generated multirate layout for {schema_name} " | |
| f"with {len(options)} items and {len(ratings)} rating options") | |
| return html, [] # No key bindings implemented | |
| def _arrange_items_horizontally(items, num_columns): | |
| """ | |
| Arrange items in a horizontal layout with specified number of columns. | |
| Args: | |
| items (list): List of processed item dictionaries | |
| num_columns (int): Number of columns | |
| Returns: | |
| list: List of rows, where each row is a list of items | |
| """ | |
| rows = [] | |
| for i in range(0, len(items), num_columns): | |
| row = items[i:i+num_columns] | |
| # Pad the row if it's not full | |
| while len(row) < num_columns: | |
| row.append(None) | |
| rows.append(row) | |
| return rows | |
| def _arrange_items_vertically(items, num_columns): | |
| """ | |
| Rearrange items for vertical column layout. | |
| Args: | |
| items (list): List of processed item dictionaries | |
| num_columns (int): Number of columns | |
| Returns: | |
| list: List of rows, where each row is a list of items arranged vertically | |
| """ | |
| logger.debug(f"Rearranging {len(items)} items into {num_columns} vertical columns") | |
| # Calculate rows needed | |
| num_rows = (len(items) + num_columns - 1) // num_columns # Ceiling division | |
| # Distribute items into columns | |
| columns = [[] for _ in range(num_columns)] | |
| for i, item in enumerate(items): | |
| col_idx = i // num_rows | |
| if col_idx < num_columns: | |
| columns[col_idx].append(item) | |
| # Create rows from columns | |
| rows = [] | |
| for row_idx in range(num_rows): | |
| row = [] | |
| for col in columns: | |
| row.append(col[row_idx] if row_idx < len(col) else None) | |
| rows.append(row) | |
| return rows | |
| def _generate_tooltip(label_data): | |
| """ | |
| Generate tooltip HTML attribute from label data. | |
| Args: | |
| label_data (dict): Label configuration containing tooltip information | |
| Returns: | |
| str: Tooltip HTML attribute or empty string if no tooltip | |
| """ | |
| tooltip_text = "" | |
| if "tooltip" in label_data: | |
| tooltip_text = label_data["tooltip"] | |
| elif "tooltip_file" in label_data: | |
| try: | |
| with open(label_data["tooltip_file"], "rt", encoding="utf-8") as f: | |
| tooltip_text = "".join(f.readlines()) | |
| except Exception as e: | |
| logger.error(f"Failed to read tooltip file: {e}") | |
| return "" | |
| if tooltip_text: | |
| escaped_tooltip = escape_html_content(tooltip_text) | |
| return f'data-toggle="tooltip" data-html="true" data-placement="top" title="{escaped_tooltip}"' | |
| return "" | |
| # JavaScript template for dynamic multirate (options_from_data) | |
| DYNAMIC_MULTIRATE_JS = """ | |
| <script> | |
| (function() { | |
| var container = document.getElementById('dynamic-multirate-{{ schema_name }}'); | |
| if (!container) return; | |
| var dataKey = container.getAttribute('data-options-from-data'); | |
| var ratings = JSON.parse(container.getAttribute('data-ratings')); | |
| var schemaName = {{ schema_name_js }}; | |
| var validation = {{ validation_js }}; | |
| // Try to find instance data - look in various places | |
| function getInstanceData() { | |
| // Check for display field data attributes | |
| var fields = document.querySelectorAll('[data-field-key="' + dataKey + '"]'); | |
| if (fields.length > 0) { | |
| var text = fields[0].textContent; | |
| try { return JSON.parse(text); } catch(e) {} | |
| } | |
| // Check for raw instance data in script tag | |
| var instanceScript = document.getElementById('instance'); | |
| if (instanceScript) { | |
| try { | |
| var data = JSON.parse(instanceScript.textContent); | |
| if (data.text && typeof data.text === 'object') return data.text[dataKey]; | |
| } catch(e) {} | |
| } | |
| return null; | |
| } | |
| function escapeHtml(str) { | |
| var div = document.createElement('div'); | |
| div.appendChild(document.createTextNode(str)); | |
| return div.innerHTML; | |
| } | |
| function escapeAttr(str) { | |
| return str.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>'); | |
| } | |
| function buildTable(options) { | |
| if (!options || !Array.isArray(options) || options.length === 0) { | |
| container.innerHTML = '<p style="color:#666;font-style:italic">No dynamic options available for this instance.</p>'; | |
| return; | |
| } | |
| var html = '<table class="shadcn-multirate-table"><thead><tr><th> </th>'; | |
| for (var r = 0; r < ratings.length; r++) { | |
| html += '<th>' + escapeHtml(ratings[r]) + '</th>'; | |
| } | |
| html += '</tr></thead><tbody>'; | |
| for (var i = 0; i < options.length; i++) { | |
| var label = typeof options[i] === 'string' ? options[i] : (options[i].label || options[i].name || String(i)); | |
| var safeName = schemaName + ':::' + label.replace(/[^a-zA-Z0-9_]/g, '_'); | |
| html += '<tr schema="multirate"><td>' + escapeHtml(label) + '</td>'; | |
| for (var r = 0; r < ratings.length; r++) { | |
| html += '<td class="shadcn-radio-cell"><input name="' + escapeAttr(safeName) + | |
| '" type="radio" id="' + escapeAttr(safeName) + '.' + escapeAttr(ratings[r]) + | |
| '" value="' + escapeAttr(ratings[r]) + | |
| '" onclick="this.blur();" validation="' + escapeAttr(validation) + | |
| '" class="shadcn-multirate-radio annotation-input" schema="' + | |
| escapeAttr(schemaName) + '" label_name="' + escapeAttr(label) + | |
| '" aria-label="' + escapeAttr(label + ': ' + ratings[r]) + '" /></td>'; | |
| } | |
| html += '</tr>'; | |
| } | |
| html += '</tbody></table>'; | |
| container.querySelector('.dynamic-multirate-body').innerHTML = html; | |
| } | |
| // Build table from data attribute (server-injected) | |
| var preloadedData = container.getAttribute('data-options-values'); | |
| if (preloadedData) { | |
| try { buildTable(JSON.parse(preloadedData)); } catch(e) { console.error('Dynamic multirate parse error:', e); } | |
| } else { | |
| var data = getInstanceData(); | |
| if (data) buildTable(data); | |
| } | |
| })(); | |
| </script> | |
| """ | |
| DYNAMIC_MULTIRATE_TEMPLATE = """ | |
| <form id="{{ schema_name }}" class="annotation-form multirate shadcn-multirate-container" | |
| action="javascript:void(0)" data-annotation-id="{{ annotation_id }}" {{ layout_attrs }}> | |
| <fieldset schema="{{ schema_name }}"> | |
| <legend class="shadcn-multirate-title">{{ description }}</legend> | |
| <div id="dynamic-multirate-{{ schema_name }}" | |
| class="dynamic-multirate-container" | |
| data-options-from-data="{{ data_key }}" | |
| data-ratings='{{ ratings_json }}' | |
| data-options-values='{{ options_values_json }}'> | |
| <div class="dynamic-multirate-body"> | |
| <p style="color:#666;font-style:italic">Loading options from instance data...</p> | |
| </div> | |
| </div> | |
| </fieldset> | |
| </form> | |
| """ | |
| def _generate_dynamic_multirate(annotation_scheme, options_from_data): | |
| """ | |
| Generate a dynamic multirate layout that reads options from instance data. | |
| The generated HTML includes a JavaScript snippet that populates the multirate | |
| table at page load time using data from the instance's specified field. | |
| Args: | |
| annotation_scheme: Schema configuration dict | |
| options_from_data: Name of the instance data field containing options | |
| Returns: | |
| tuple: (html_string, key_bindings) | |
| """ | |
| import json | |
| import html as html_module | |
| schema_name = annotation_scheme['name'] | |
| description = annotation_scheme.get('description', '') | |
| ratings = annotation_scheme.get('labels', []) | |
| validation = generate_validation_attribute(annotation_scheme) | |
| layout_attrs = generate_layout_attributes(annotation_scheme) | |
| ratings_json = json.dumps(ratings) | |
| template_data = { | |
| 'schema_name': escape_html_content(schema_name), | |
| 'description': escape_html_content(description), | |
| 'data_key': escape_html_content(options_from_data), | |
| 'ratings_json': html_module.escape(ratings_json), | |
| 'options_values_json': '[]', # Will be filled by server at render time | |
| 'annotation_id': annotation_scheme.get('annotation_id', ''), | |
| 'layout_attrs': layout_attrs, | |
| 'validation': validation, | |
| # JS-safe values using json.dumps to produce quoted strings | |
| 'schema_name_js': json.dumps(schema_name), | |
| 'validation_js': json.dumps(validation), | |
| } | |
| template = Template(DYNAMIC_MULTIRATE_TEMPLATE + DYNAMIC_MULTIRATE_JS) | |
| html = template.render(**template_data) | |
| logger.info(f"Generated dynamic multirate for {schema_name} " | |
| f"reading options from '{options_from_data}'") | |
| return html, [] | |
| def populate_dynamic_multirate(html_str, instance_data): | |
| """ | |
| Post-process rendered HTML to inject instance-specific options into | |
| dynamic multirate schemas. | |
| Called from render_page_with_annotations() after template rendering. | |
| Args: | |
| html_str: The rendered HTML string | |
| instance_data: The instance data dictionary | |
| Returns: | |
| Modified HTML string with dynamic multirate options populated | |
| """ | |
| import json | |
| import re | |
| import html as html_module | |
| # Find all dynamic multirate containers | |
| pattern = r'data-options-from-data="([^"]+)"' | |
| matches = list(re.finditer(pattern, html_str)) | |
| # Process in reverse order so earlier match offsets remain valid | |
| for match in reversed(matches): | |
| data_key = html_module.unescape(match.group(1)) | |
| options = instance_data.get(data_key, []) | |
| if options and isinstance(options, list): | |
| # Use html.escape on the JSON to safely embed in a single-quoted attribute | |
| options_attr = html_module.escape(json.dumps(options)) | |
| # Search for the placeholder attribute near this match | |
| search_start = max(0, match.start() - 200) | |
| search_end = min(len(html_str), match.end() + 500) | |
| local_html = html_str[search_start:search_end] | |
| # Handle both single-quoted and double-quoted attributes | |
| # (BeautifulSoup may convert single quotes to double quotes) | |
| old_attr_single = "data-options-values='[]'" | |
| old_attr_double = 'data-options-values="[]"' | |
| if old_attr_single in local_html: | |
| html_str = html_str[:search_start] + local_html.replace( | |
| old_attr_single, | |
| f"data-options-values='{options_attr}'", | |
| 1, | |
| ) + html_str[search_end:] | |
| elif old_attr_double in local_html: | |
| html_str = html_str[:search_start] + local_html.replace( | |
| old_attr_double, | |
| f'data-options-values="{options_attr}"', | |
| 1, | |
| ) + html_str[search_end:] | |
| return html_str |