Merge pull request #737 from cosmocode/hsbund

hsbund improvements
This commit is contained in:
Andreas Gohr
2025-05-12 12:37:54 +02:00
committed by GitHub
24 changed files with 569 additions and 86 deletions

View File

@ -33,6 +33,40 @@ class SearchConfigTest extends StructTest
$this->assertEquals(date('Y-m-d'), $searchConfig->applyFilterVars('$DATE(now)$'));
}
public function test_filtervars_nsorid()
{
global $INFO;
$searchConfig = new SearchConfig([]);
// normal page
$INFO['id'] = 'foo:bar:baz';
$this->assertEquals('foo:bar:baz', $searchConfig->applyFilterVars('$NSORID$'));
// start page: start in namespace
$INFO['id'] = 'foo:bar:start';
saveWikiText($INFO['id'], 'start page', 'start created');
$this->assertEquals('foo:bar', $searchConfig->applyFilterVars('$NSORID$'));
saveWikiText($INFO['id'], '', 'start page deleted');
clearstatcache();
// start page: same as namespace in namespace
$INFO['id'] = 'foo:bar:bar';
saveWikiText($INFO['id'], 'start page', 'start created');
$this->assertEquals('foo:bar', $searchConfig->applyFilterVars('$NSORID$'));
saveWikiText($INFO['id'], '', 'start page deleted');
clearstatcache();
// start page: same as namespace in above namespace
// incidally this is the same as a normal page
$INFO['id'] = 'foo:bar';
saveWikiText($INFO['id'], 'start page', 'start created');
$this->assertEquals('foo:bar', $searchConfig->applyFilterVars('$NSORID$'));
saveWikiText($INFO['id'], '', 'start page deleted');
clearstatcache();
}
public function test_filtervars_struct()
{
global $INFO;

View File

@ -413,7 +413,7 @@ class action_plugin_struct_migration extends ActionPlugin
}
}
if (!empty($fixes)) {
if ($fixes !== []) {
$fixes = array_map(static fn($set, $key) => "$key = '$set'", $fixes, array_keys($fixes));
}

View File

@ -20,7 +20,7 @@ class helper_plugin_struct_config extends Plugin
*/
public function parseSort($val)
{
if (substr($val, 0, 1) == '^') {
if (str_starts_with($val, '^')) {
return [substr($val, 1), false];
}
return [$val, true];

View File

@ -28,6 +28,10 @@ jQuery(function () {
this.updateEditor = function () {
editor.setText($config.val());
};
// collapse all nodes except for the new element
if(!$config.parents('.new').length) {
editor.collapseAll();
}
});
/**

View File

@ -81,7 +81,6 @@ $lang['Exception No data saved'] = 'No data saved';
$lang['Exception no sqlite'] = 'The struct plugin requires the sqlite plugin. Please install and enable it.';
$lang['Exception column not in table'] = 'There is no column %s in schema %s.';
$lang['Exception datefilter'] = 'The filter: \'<code>$Date(%s)$</code>\' contains an unsupported value.';
$lang['Warning: no filters for cloud'] = 'Filters are not supported for struct clouds.';
$lang['sort'] = 'Sort by this column';
$lang['next'] = 'Next page';
$lang['prev'] = 'Previous page';

View File

@ -291,12 +291,12 @@ class Assignments
}
$ans = ':' . cleanID($pattern) . ':';
if (substr($pattern, -2) == '**') {
if (str_ends_with($pattern, '**')) {
// upper namespaces match
if (strpos($pns, $ans) === 0) {
if (str_starts_with($pns, $ans)) {
return true;
}
} elseif (substr($pattern, -1) == '*') {
} elseif (str_ends_with($pattern, '*')) {
// namespaces match exact
if ($ans == $pns) {
return true;

View File

@ -193,9 +193,9 @@ class Column
$files = glob(DOKU_PLUGIN . 'struct/types/*.php');
foreach ($files as $file) {
$file = basename($file, '.php');
if (substr($file, 0, 8) == 'Abstract') continue;
if (substr($file, 0, 5) == 'Trait') continue;
if (substr($file, 0, 4) == 'Auto') continue;
if (str_starts_with($file, 'Abstract')) continue;
if (str_starts_with($file, 'Trait')) continue;
if (str_starts_with($file, 'Auto')) continue;
$map[$file] = 'dokuwiki\\plugin\\struct\\types\\' . $file;
}

View File

@ -30,7 +30,8 @@ class ConfigParser
'csv' => true,
'nesting' => 0,
'index' => 0,
'classes' => []
'classes' => [],
'actcol' => -1,
];
/**
@ -131,6 +132,11 @@ class ConfigParser
case 'classes':
$this->config['classes'] = $this->parseClasses($val);
break;
case 'actcol':
case 'actioncol':
case 'actioncolumn':
$this->config['actcol'] = (int) $val;
break;
default:
$data = ['config' => &$this->config, 'key' => $key, 'val' => $val];
$ev = new Event('PLUGIN_STRUCT_CONFIGPARSER_UNKNOWNKEY', $data);

View File

@ -63,7 +63,7 @@ class SchemaEditor
$form->addHTML($this->adminColumn($col->getColref(), $col));
}
// FIXME new one needs to be added dynamically, this is just for testing
// Add one new field at the end
$form->addHTML($this->adminColumn('new1', new Column($this->schema->getMaxsort() + 10, new Text()), 'new'));
$form->addHTML('</table>');
@ -117,6 +117,7 @@ class SchemaEditor
$base = 'schema[' . $key . '][' . $column_id . ']'; // base name for all fields
$class = $col->isEnabled() ? '' : 'disabled';
if ($key === 'new') $class .= ' new';
$html = "<tr class=\"$class\">";

View File

@ -14,48 +14,40 @@ class SearchCloud extends SearchConfig
protected $limit = '';
/**
* Transform the set search parameters into a statement
* We do not have pagination in clouds, so we can work with a limit within SQL
*
* @return array ($sql, $opts) The SQL and parameters to execute
* @param int $limit
*/
public function getSQL()
public function setLimit($limit)
{
if (!$this->columns) throw new StructException('nocolname');
$this->limit = " LIMIT $limit";
}
$QB = new QueryBuilder();
reset($this->schemas);
$schema = current($this->schemas);
$datatable = 'data_' . $schema->getTable();
/**
* @inheritdoc
*/
protected function runSQLBuilder()
{
$sqlBuilder = new SearchSQLBuilder();
$sqlBuilder->setSelectLatest($this->selectLatest);
$sqlBuilder->addSchemas($this->schemas, false);
$this->addTagSelector($sqlBuilder);
$sqlBuilder->getQueryBuilder()->addGroupByStatement('tag');
$sqlBuilder->getQueryBuilder()->addOrderBy('count DESC');
$sqlBuilder->addFilters($this->filter);
return $sqlBuilder;
}
$QB->addTable($datatable);
// add conditional page clauses if pid has a value
$subAnd = $QB->filters()->whereSubAnd();
$subAnd->whereAnd("$datatable.pid = ''");
$subOr = $subAnd->whereSubOr();
$subOr->whereAnd("GETACCESSLEVEL($datatable.pid) > 0");
$subOr->whereAnd("PAGEEXISTS($datatable.pid) = 1");
$subOr->whereSubOr()
->whereAnd('ASSIGNED == 1')
->whereSubOr()
->whereAnd("$datatable.rid > 0")
->whereAnd("ASSIGNED IS NULL");
// add conditional schema assignment check
$QB->addLeftJoin(
$datatable,
'schema_assignments',
'',
"$datatable.pid != ''
AND $datatable.pid = schema_assignments.pid
AND schema_assignments.tbl = '{$schema->getTable()}'"
);
$QB->filters()->whereAnd("$datatable.latest = 1");
$QB->filters()->where('AND', 'tag IS NOT \'\'');
/**
* Add the tag selector to the SQLBuilder
*/
protected function addTagSelector(SearchSQLBuilder $builder)
{
$QB = $builder->getQueryBuilder();
$col = $this->columns[0];
$datatable = "data_{$col->getTable()}";
if ($col->isMulti()) {
$multitable = "multi_{$col->getTable()}";
$MN = $QB->generateTableAlias('M');
@ -77,23 +69,8 @@ class SearchCloud extends SearchConfig
$colname = $datatable . '.' . $col->getColName();
}
$QB->addSelectStatement("COUNT($colname)", 'count');
$QB->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED');
$QB->addGroupByStatement('tag');
$QB->addOrderBy('count DESC');
[$sql, $opts] = $QB->getSQL();
return [$sql . $this->limit, $opts];
}
/**
* We do not have pagination in clouds, so we can work with a limit within SQL
*
* @param int $limit
*/
public function setLimit($limit)
{
$this->limit = " LIMIT $limit";
}
/**
* Execute this search and return the result

View File

@ -2,6 +2,8 @@
namespace dokuwiki\plugin\struct\meta;
use dokuwiki\File\PageResolver;
/**
* Class SearchConfig
*
@ -93,9 +95,9 @@ class SearchConfig extends Search
foreach ($filters as $filter) {
if (is_array($filter)) $filter = $filter[2]; // this is the format we get fro the config parser
if (strpos($filter, '$USER$') !== false) {
if (str_contains($filter, '$USER$')) {
$flags |= self::$CACHE_USER;
} elseif (strpos($filter, '$TODAY$') !== false) {
} elseif (str_contains($filter, '$TODAY$')) {
$flags |= self::$CACHE_DATE;
}
}
@ -116,6 +118,7 @@ class SearchConfig extends Search
if (!isset($INFO['id'])) {
$INFO['id'] = '';
}
$ns = getNS($INFO['id']);
// apply inexpensive filters first
$filter = str_replace(
@ -128,7 +131,7 @@ class SearchConfig extends Search
],
[
$INFO['id'],
getNS($INFO['id']),
$ns,
noNS($INFO['id']),
$INPUT->server->str('REMOTE_USER'),
date('Y-m-d')
@ -136,6 +139,22 @@ class SearchConfig extends Search
$filter
);
// apply namespace or id placeholder #712
// returns the namespace for start pages, otherwise the ID
if (preg_match('/\$NSORID\$/', $filter)) {
$resolver = new PageResolver('');
$start = $resolver->resolveId($ns . ':');
if ($start === $INFO['id']) {
// This is a start page, we return the namespace
$val = $ns;
} else {
// This is a normal page, we return the ID
$val = $INFO['id'];
}
$filter = str_replace('$NSORID$', $val, $filter);
}
// apply struct column placeholder (we support only one!)
// or apply date formula, given as strtotime
if (preg_match('/^(.*?)(?:\$STRUCT\.(.*?)\$)(.*?)$/', $filter, $match)) {

View File

@ -27,8 +27,9 @@ class SearchSQLBuilder
* Add the schemas to the query
*
* @param Schema[] $schemas Schema names to query
* @param bool $selectMeta Select meta columns (pid, rid, rev, assigned)
*/
public function addSchemas($schemas)
public function addSchemas($schemas, $selectMeta = true)
{
// basic tables
$first_table = '';
@ -60,12 +61,14 @@ class SearchSQLBuilder
AND schema_assignments.tbl = '{$schema->getTable()}'"
);
$this->qb->addSelectColumn($datatable, 'rid');
$this->qb->addSelectColumn($datatable, 'pid', 'PID');
$this->qb->addSelectColumn($datatable, 'rev');
$this->qb->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED');
$this->qb->addGroupByColumn($datatable, 'pid');
$this->qb->addGroupByColumn($datatable, 'rid');
if ($selectMeta) {
$this->qb->addSelectColumn($datatable, 'rid');
$this->qb->addSelectColumn($datatable, 'pid', 'PID');
$this->qb->addSelectColumn($datatable, 'rev');
$this->qb->addSelectColumn('schema_assignments', 'assigned', 'ASSIGNED');
$this->qb->addGroupByColumn($datatable, 'pid');
$this->qb->addGroupByColumn($datatable, 'rid');
}
$first_table = $datatable;
}

View File

@ -168,7 +168,7 @@ class renderer_plugin_struct_csv extends Doku_Renderer
public function plugin($name, $args, $state = '', $match = '')
{
if (substr($name, 0, 7) == 'struct_') {
if (str_starts_with($name, 'struct_')) {
parent::plugin($name, $args, $state, $match);
} else {
$this->cdata($match);

View File

@ -5,12 +5,13 @@ jQuery(function () {
/* DOKUWIKI:include script/AggregationEditor.js */
/* DOKUWIKI:include script/InlineEditor.js */
/* DOKUWIKI:include script/StructFilter.js */
/* DOKUWIKI:include_once script/vanilla-combobox.js */
function init() {
EntryEditor(jQuery('#dw__editform, form.bureaucracy__plugin'));
SchemaEditor();
jQuery('div.structaggregationeditor table').each(AggregationEditor);
InlineEditor(jQuery('div.structaggregation table'));
InlineEditor(jQuery('div.structaggregation table, #plugin__struct_output table'));
}
jQuery(init);

View File

@ -31,7 +31,7 @@ const AggregationEditor = function (idx, table) {
// empty header cells
if (!rid) {
$me.append('<th class="action">' + LANG.plugins.struct.actions + '</th>');
insertActionCell($me, '<th class="action">' + LANG.plugins.struct.actions + '</th>');
return;
}
@ -68,11 +68,30 @@ const AggregationEditor = function (idx, table) {
});
$td.append($btn);
$me.append($td);
insertActionCell($me, $td);
});
}
/**
* Insert the action cell at the right position, depending on the actcol setting
*
* @param {jQuery<HTMLTableRowElement>} $row
* @param {jQuery<HTMLTableCellElement>} $cell
*/
function insertActionCell($row, $cell) {
const $children = $row.children();
let insertAt = searchconf.actcol;
if ( insertAt < 0 ) insertAt = $children.length + 1 + insertAt;
if(insertAt >= $children.length) {
$row.append($cell);
} else if (insertAt < 0) {
$row.prepend($cell);
} else {
$children.eq(insertAt).before($cell);
}
}
/**
* Initializes the form for the editor and attaches handlers
*

View File

@ -12,7 +12,8 @@ var InlineEditor = function ($table) {
var pid = $self.parent().data('pid');
var rid = $self.parent().data('rid');
var rev = $self.parent().data('rev');
var field = $self.parents('table').find('tr th').eq($self.index()).data('field');
var field = $self.parent().data('field') ||
$self.parents('table').find('tr th').eq($self.index()).data('field');
if ((!pid && !rid) || !field) return;

394
script/vanilla-combobox.js Normal file
View File

@ -0,0 +1,394 @@
/**
* A custom web component that transforms a standard HTML select into a combo box,
* allowing users to both select from a dropdown and search by typing.
*/
/* */
class VanillaCombobox extends HTMLElement {
#select;
#input;
#dropdown;
#separator;
#multiple;
#placeholder;
#outsideClickListener;
// region initialization
/**
* Creates a new VanillaCombobox instance.
* Initializes the shadow DOM.
*/
constructor() {
super();
this.attachShadow({mode: 'open'});
}
/**
* Called when the element is connected to the DOM.
* Initializes the component, sets up the shadow DOM, and binds event listeners.
* @returns {void}
*/
connectedCallback() {
this.#separator = this.getAttribute('separator') || ',';
this.#placeholder = this.getAttribute('placeholder') || '';
this.#setupShadowDOM();
this.#initializeStyles();
this.#updateInputFromSelect();
this.#registerEventListeners();
}
/**
* Most event handlers will be garbage collected when the element is removed from the DOM.
* However, we need to remove the outside click listener attached to the document to prevent memory leaks.
* @returns {void}
*/
disconnectedCallback() {
document.removeEventListener('click', this.#outsideClickListener);
}
/**
* Sets up the shadow DOM with the necessary elements and styles.
* Creates the input field and dropdown container.
* @private
* @returns {void}
*/
#setupShadowDOM() {
// Get the select element from the light DOM
this.#select = this.querySelector('select');
if (!this.#select) {
console.error('VanillaCombobox: No select element found');
return;
}
this.#multiple = this.#select.multiple;
// Create the input field
this.#input = document.createElement('input');
this.#input.type = 'text';
this.#input.autocomplete = 'off';
this.#input.part = 'input';
this.#input.placeholder = this.#placeholder;
this.#input.required = this.#select.required;
this.#input.disabled = this.#select.disabled;
// Create the dropdown container
this.#dropdown = document.createElement('div');
this.#dropdown.className = 'dropdown';
this.#dropdown.part = 'dropdown';
this.#dropdown.style.display = 'none';
// Add styles to the shadow DOM
const style = document.createElement('style');
style.textContent = `
:host {
display: inline-block;
position: relative;
}
.dropdown {
position: absolute;
max-height: 200px;
overflow-y: auto;
border: 1px solid FieldText;
background-color: Field;
color: FieldText;
z-index: 1000;
width: max-content;
box-sizing: border-box;
}
.option {
padding: 5px 10px;
cursor: pointer;
}
.option:hover, .option.selected {
background-color: Highlight;
color: HighlightText;
}
`;
// Append elements to the shadow DOM
this.shadowRoot.appendChild(style);
this.shadowRoot.appendChild(this.#input);
this.shadowRoot.appendChild(this.#dropdown);
}
/**
* Initializes the styles for the combobox components.
* Copies styles from browser defaults and applies them to the custom elements.
* @private
* @returns {void}
*/
#initializeStyles() {
// create a temporary input element to copy styles from
const input = document.createElement('input');
this.parentElement.insertBefore(input, this);
const defaultStyles = window.getComputedStyle(input);
// browser default styles
const inputStyles = window.getComputedStyle(this.#input);
const dropdownStyles = window.getComputedStyle(this.#dropdown);
// copy styles from the temporary input to the input element
for (const property of defaultStyles) {
const newValue = defaultStyles.getPropertyValue(property);
const oldValue = inputStyles.getPropertyValue(property);
if (newValue === oldValue) continue;
this.#input.style.setProperty(property, newValue);
}
this.#input.style.outline = 'none';
// copy select styles to the dropdown
for (const property of defaultStyles) {
const newValue = defaultStyles.getPropertyValue(property);
const oldValue = dropdownStyles.getPropertyValue(property);
if (newValue === oldValue) continue;
if (!property.match(/^(border|color|background|font|padding)/)) continue;
this.#dropdown.style.setProperty(property, newValue);
}
this.#dropdown.style.minWidth = `${this.#input.offsetWidth}px`;
this.#dropdown.style.borderTop = 'none';
// remove the temporary input element
this.parentElement.removeChild(input);
}
// endregion
// region Event Handling
/**
* Registers all event listeners for the combobox.
* Sets up input, focus, blur, and keyboard events.
* @private
* @returns {void}
*/
#registerEventListeners() {
this.#input.addEventListener('focus', this.#onFocus.bind(this));
// Delay to allow click event on dropdown
this.#input.addEventListener('blur', () => setTimeout(() => this.#onBlur(), 150));
this.#input.addEventListener('input', this.#onInput.bind(this));
this.#input.addEventListener('keydown', this.#onKeyDown.bind(this));
this.#outsideClickListener = (event) => {
if (this.contains(event.target) || this.shadowRoot.contains(event.target)) return;
this.#closeDropdown();
};
document.addEventListener('click', this.#outsideClickListener);
}
/**
* Handles the focus event on the input field.
* Updates the values and appends the separator if multiple selection is enabled.
* Shows the dropdown with available options.
* @private
* @returns {void}
*/
#onFocus() {
this.#updateInputFromSelect();
this.#showDropdown();
}
/**
* Handles the blur event on the input field.
* Closes the dropdown and updates the input field (removes the separator).
* Synchronizes the select element with the input value.
* @private
* @returns {void}
*/
#onBlur() {
this.#closeDropdown();
this.#updateSelectFromInput();
this.#updateInputFromSelect();
}
/**
* Handles the input event on the input field.
* Shows the dropdown with filtered options based on the current input.
* @private
* @returns {void}
*/
#onInput() {
this.#showDropdown();
}
/**
* Handles keyboard navigation in the dropdown.
* Supports arrow keys, Enter, and Escape.
* @private
* @param {KeyboardEvent} event - The keyboard event
* @returns {void}
*/
#onKeyDown(event) {
// Only handle keyboard navigation if dropdown is visible
if (this.#dropdown.style.display !== 'block') return;
const items = this.#dropdown.querySelectorAll('.option');
const selectedItem = this.#dropdown.querySelector('.option.selected');
let selectedIndex = Array.from(items).indexOf(selectedItem);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = (selectedIndex + 1) % items.length;
this.#highlightItem(items, selectedIndex);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
this.#highlightItem(items, selectedIndex);
break;
case 'Enter':
event.preventDefault();
if (selectedItem) {
selectedItem.click();
}
break;
case 'Escape':
event.preventDefault();
this.#dropdown.style.display = 'none';
break;
}
}
// endregion
// region Data Handling
/**
* Updates the input field value based on the selected options in the select element.
* Joins multiple selections with the separator if multiple selection is enabled.
* @private
* @returns {void}
*/
#updateInputFromSelect() {
const selectedOptions = Array.from(this.#select.selectedOptions);
if (selectedOptions.length > 0) {
this.#input.value = selectedOptions
.map(option => option.textContent)
.join(`${this.#separator} `);
} else {
this.#input.value = '';
}
// If the input is focused and multiple selection is enabled, append the separator
if ((this.shadowRoot.activeElement === this.#input) && this.#multiple && this.#input.value !== '') {
this.#input.value += this.#separator + ' ';
}
}
/**
* Gets all selected options and unselects those whose text is no longer in the input.
* Synchronizes the select element with the input field content.
* @private
* @returns {void}
*/
#updateSelectFromInput() {
const selectedOptions = Array.from(this.#select.selectedOptions);
let inputTexts = [this.#input.value];
if (this.#multiple) {
inputTexts = this.#input.value.split(this.#separator).map(text => text.trim());
}
selectedOptions.forEach(option => {
if (!inputTexts.includes(option.textContent)) {
option.selected = false;
}
})
}
// endregion
// region Dropdown Handling
/**
* Shows the dropdown with options filtered by the current input value.
* Creates option elements for each matching option.
* @private
* @returns {void}
*/
#showDropdown() {
// get the currently edited value
let query = this.#input.value.trim();
if (this.#multiple) {
query = query.split(this.#separator).pop().trim()
}
// Filter the options matching the input value
const options = Array.from(this.#select.options);
const filteredOptions = options.filter(
option => option.textContent.toLowerCase().includes(query.toLowerCase())
);
if (filteredOptions.length === 0) {
this.#closeDropdown();
return;
}
// Create the dropdown items
this.#dropdown.innerHTML = '';
filteredOptions.forEach(option => {
if (this.#multiple && option.value === '') return; // Ignore empty options in multiple mode
const div = document.createElement('div');
div.textContent = option.textContent;
div.className = 'option';
div.part = 'option';
this.#dropdown.appendChild(div);
// Add click event to each option
div.addEventListener('click', () => this.#selectOption(option));
});
// Show the dropdown
this.#dropdown.style.display = 'block';
}
/**
* Closes the dropdown by hiding it.
* @private
* @returns {void}
*/
#closeDropdown() {
this.#dropdown.style.display = 'none';
}
/**
* Highlights a specific item in the dropdown.
* Removes selection from all items and adds it to the specified one.
* @private
* @param {NodeListOf<Element>} items - The dropdown items
* @param {number} index - The index of the item to highlight
* @returns {void}
*/
#highlightItem(items, index) {
// Remove selection from all items
items.forEach(item => item.classList.remove('selected'));
// Add selection to current item
if (items[index]) {
items[index].classList.add('selected');
// Ensure the selected item is visible in the dropdown
items[index].scrollIntoView({block: 'nearest'});
}
}
/**
* Selects an option from the dropdown.
* Updates the select element and input field, then closes the dropdown.
* @private
* @param {HTMLOptionElement} option - The option to select
* @returns {void}
*/
#selectOption(option) {
option.selected = true;
this.#updateInputFromSelect();
this.#closeDropdown();
this.#input.focus();
}
// endregion
}
// Register the custom element
customElements.define('vanilla-combobox', VanillaCombobox);

View File

@ -88,9 +88,6 @@ class syntax_plugin_struct_cloud extends SyntaxPlugin
{
if ($mode != 'xhtml') return false;
if (!$data) return false;
if (!empty($data['filter'])) {
msg($this->getLang('Warning: no filters for cloud'), -1);
}
global $INFO, $conf;
try {
$search = new SearchCloud($data);

View File

@ -7,6 +7,7 @@
* @author Andreas Gohr, Michael Große <dokuwiki@cosmocode.de>
*/
use dokuwiki\plugin\struct\meta\Value;
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\Extension\Event;
use dokuwiki\plugin\struct\meta\AccessTable;
@ -182,7 +183,18 @@ class syntax_plugin_struct_output extends SyntaxPlugin
$renderer->tabletbody_open();
foreach ($data as $field) {
/** @var Value $field */
$renderer->tablerow_open();
if ($format == 'xhtml') {
// add data attributes to the row for inline editing
$renderer->doc = substr(trim($renderer->doc), 0, -1); // remove the last >
$renderer->doc .= ' data-pid="' . hsc($schemadata->getPid()) . '"';
$renderer->doc .= ' data-rev="' . hsc($schemadata->getTimestamp()) . '"';
$renderer->doc .= ' data-rid="' . hsc($schemadata->getRid()) . '"';
$renderer->doc .= ' data-field="' . hsc($field->getColumn()->getFullQualifiedLabel()) . '"';
$renderer->doc .= '">';
}
$renderer->tableheader_open();
$renderer->cdata($field->getColumn()->getTranslatedLabel());
$renderer->tableheader_close();

View File

@ -81,7 +81,7 @@ class Decimal extends AbstractMultiBaseType
$this->config['thousands']
);
}
if ($this->config['trimzeros'] && (strpos($value, (string) $this->config['decpoint']) !== false)) {
if ($this->config['trimzeros'] && (str_contains($value, (string) $this->config['decpoint']))) {
$value = rtrim($value, '0');
$value = rtrim($value, $this->config['decpoint']);
}

View File

@ -5,7 +5,8 @@ namespace dokuwiki\plugin\struct\types;
class Dropdown extends AbstractBaseType
{
protected $config = [
'values' => 'one, two, three'
'values' => 'one, two, three',
'combobox' => false,
];
/**
@ -50,6 +51,10 @@ class Dropdown extends AbstractBaseType
}
$html .= '</select>';
if ($this->config['combobox']){
$html = "<vanilla-combobox>$html</vanilla-combobox>";
}
return $html;
}
@ -84,6 +89,11 @@ class Dropdown extends AbstractBaseType
}
$html .= '</select> ';
$html .= '<small>' . $this->getLang('multidropdown') . '</small>';
if ($this->config['combobox']){
$html = "<vanilla-combobox>$html</vanilla-combobox>";
}
return $html;
}
}

View File

@ -16,7 +16,11 @@ use dokuwiki\plugin\struct\meta\Value;
class Lookup extends Dropdown
{
protected $config = ['schema' => '', 'field' => ''];
protected $config = [
'schema' => '',
'field' => '',
'combobox' => false,
];
/** @var Column caches the referenced column */
protected $column;

View File

@ -31,7 +31,7 @@ class Media extends AbstractBaseType
[, $mime, ] = mimetype($rawvalue, false);
foreach ($allows as $allow) {
if (strpos($mime, $allow) === 0) return $rawvalue;
if (str_starts_with($mime, $allow)) return $rawvalue;
}
throw new ValidationException('Media mime type', $mime, $this->config['mime']);
@ -77,7 +77,7 @@ class Media extends AbstractBaseType
// add gallery meta data in XHTML
if ($mode == 'xhtml') {
[, $mime, ] = mimetype($value, false);
if (substr($mime, 0, 6) == 'image/') {
if (str_starts_with($mime, 'image/')) {
$hash = empty($R->info['struct_table_hash']) ? '' : "[gal-" . $R->info['struct_table_hash'] . "]";
$html = str_replace('href', "rel=\"lightbox$hash\" href", $html);
}

View File

@ -26,12 +26,14 @@ class Tag extends AbstractMultiBaseType
*/
public function renderValue($value, \Doku_Renderer $R, $mode)
{
global $INFO;
$context = $this->getContext();
$filter = SearchConfigParameters::$PARAM_FILTER .
'[' . $context->getTable() . '.' . $context->getLabel() . '*~]=' . $value;
$page = trim($this->config['page']);
if (!$page) $page = cleanID($context->getLabel());
if (!$page) $page = $INFO['id'];
$R->internallink($page . '?' . $filter, $value);
return true;