diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7deeef88c..77d9e46b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,42 +18,47 @@ jobs: matrix: include: - mediawiki_version: '1.39' - smw_version: dev-master + smw_version: 5.1.0 php_version: 8.1 + mm_version: 6.0.1 database_type: mysql - database_image: "mariadb:10" + database_image: "mariadb:11.2" coverage: false experimental: false - mediawiki_version: '1.40' - smw_version: dev-master + smw_version: 5.1.0 php_version: 8.1 + mm_version: 6.0.1 database_type: mysql database_image: "mariadb:11.2" coverage: true experimental: false - mediawiki_version: '1.41' - smw_version: dev-master + smw_version: 5.1.0 pf_version: 5.9 sfs_version: dev-master php_version: 8.1 + mm_version: 6.0.1 database_type: mysql database_image: "mariadb:11.2" coverage: false experimental: false - mediawiki_version: '1.42' - smw_version: dev-master + smw_version: 5.1.0 pf_version: 5.9 sfs_version: dev-master php_version: 8.1 + mm_version: 6.0.1 database_type: mysql database_image: "mariadb:11.2" coverage: false experimental: false - - mediawiki_version: '1.43' + - mediawiki_version: '1.43.1' smw_version: dev-master pf_version: 5.9 sfs_version: dev-master php_version: 8.1 + mm_version: 6.0.1 database_type: mysql database_image: "mariadb:11.2" coverage: false @@ -67,6 +72,7 @@ jobs: PHP_VERSION: ${{ matrix.php_version }} DB_TYPE: ${{ matrix.database_type }} DB_IMAGE: ${{ matrix.database_image }} + MM_VERSION: ${{ matrix.mm_version }} steps: diff --git a/Makefile b/Makefile index 06e370721..29b87e15b 100644 --- a/Makefile +++ b/Makefile @@ -14,13 +14,13 @@ EXTENSION=SemanticResultFormats MW_VERSION?=1.39 PHP_VERSION?=8.1 DB_TYPE?=mysql -DB_IMAGE?="mariadb:10" +DB_IMAGE?="mariadb:11.2" # extensions -SMW_VERSION?=dev-master +SMW_VERSION ?= 5.0.2 PF_VERSION ?= 5.5.1 SFS_VERSION ?= 4.0.0-beta -MM_VERSION ?= 3.1.0 +MM_VERSION ?= 6.0.1 # composer # Enables "composer update" inside of extension diff --git a/README.md b/README.md index 3fcb46ce5..bd16da4f2 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,7 @@ Semantic Result Formats (SRF) is a MediaWiki extension that provides extra visua ## Requirements -- PHP 7.3.19 or later -- MediaWiki 1.35 or later -- Semantic MediaWiki 3.0 or later +PHP, MediaWiki, and Semantic MediaWiki. For specific supported versions, see the [compatibility overview](docs/COMPATIBILITY.md). ## Installation @@ -48,6 +46,9 @@ A list of people who have made contributions in the past can be found [here][con This extension provides unit and integration tests and is usually run by a [continues integration platform][GitHub Actions] but can also be executed locally using the shortcut command `composer phpunit` from the extension base directory. +## For developers + +See the documention on how to [update d3chart JS library](https://github.com/SemanticMediaWiki/SemanticResultFormats/blob/master/formats/d3/README.md). ## License diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index a24819d94..aec6e66ff 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,37 +1,72 @@ These are the release notes for the [Semantic Result Formats](https://www.semantic-mediawiki.org/wiki/Extension:Semantic_Result_Formats) (a.k.a SRF) MediaWiki extension. +## SRF 5.1.0 + +Released on September 3, 2025. + +### Improvements to + +* Fixed compatibility issue with MediaWiki 1.43 in the `filtered` format +* Added `sep` parameter to the `filtered` format to allow specifying the separator ([952](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/952)) +* Improved the `d3chart` format + * Refactored d3chart codebase to support newer D3 versions + * Bumped d3 from v3.0.4 to v6.7.0 ([951](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/951)) + * Introduced package.json and modern build tooling to manage D3 as an npm dependency + * Updated D3 import and initialization to align with ES module standards + * Replaced legacy d3.v3.js with modern d3.min.js for better modularity and smaller bundle size + * Revised documentation to reflect new D3 usage and build process +* Improved the `graph` format by adding `graphfieldspages` param to optionally include Page-type values as node fields ([958](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/958)) +* Fixed support for template names with spaces in the `datatables` format + ## SRF 5.0.0 -Released on TDB +Released on April 7, 2025. + +Highlights: +* Support for MediaWiki 1.43 and Semantic MediaWiki 5 +* Significant enhancements to the DataTables format -Compatibility changes +### Compatibility Changes * Added compatibility with Semantic MediaWiki 5.x -* Changed minimum PHP version from 7.3 to 8.0 -* Improved PHP support up to version 8.4 -* Changed minimum MediaWiki version from 1.35 to 1.39 -* Improved MediaWiki support up to 1.43 +* Added support for PHP up to version 8.4 +* Added support for MediaWiki support up to version 1.43 +* Dropped support for MediaWiki older than 1.39 +* Dropped support for PHP older than 8.1 -Breaking changes +### Breaking Changes -* +* Removed the Exhibit format -New features and enhancements +### New Features and Enhancements -* [864](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/864) Updated DataTables from 1.13.2 to 2.1.8 (by @alistair3149) +* Overhauled DataTables format + * Improved UI integration with MediaWiki by using [Codex](https://doc.wikimedia.org/codex/main/) [871](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/871), [879](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/879) (by @alistair3149) + * Added "show all" to DataTable length [868](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/868) (by @alistair3149) + * Fixed various sorting and filter issues [865](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/865) (by @alistair3149) + * Fixed template Ajax navigation and improved expansion [931](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/931) (by @thomas-topway-it for ([KM-A](https://km-a.net)) + * Fixed missing [ParentProperties](https://www.semantic-mediawiki.org/wiki/Help:Subobjects_and_queries) values [833](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/833) and [825](https://github.com/SemanticMediaWiki/SemanticResultFormats/issues/825) (by @hkwi) + * Bump DataTables from 1.13.2 to 2.1.8 [864](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/864) (by @alistair3149) + * Improved client-side cache mechanism [904](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/904) (by @thomas-topway-it) + * Improved accessibility with `aria-live = "polite"` attribute in DataTable [845](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/845) (by @robinwiel) +* Updated Filtered format dependencies: Fullcalendar 6 and leaflet 1.9/leaflet-providers 2.0 (by @thomas-topway-it for ([KM-A](https://km-a.net)) +* Added `prolog` format that generates SVO predicates +* Added `dataframe` format +* Improved `carousel` format with vertical centering, height adjustments, smarter captions, and nested slide [857](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/857) (by @thomas-topway-it for ([KM-A](https://km-a.net)) -Bug fixes +### Bug Fixes -* +* Fixed precision issue in the `time` format +* Fixed reuse of values output by the `incoming` format when no template is used ## SRF 4.2.1 Released on March 13, 2024. -* [815](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/815) Document graphfields parameter to the graph format (by @alex-mashin) +* [815](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/815) Document graphfields parameter to the graph format (by @alex-mashin) * [806](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/806) Added required resources for the bubble chare to the jqplotchart format (by@YOUR1) * [805](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/805) Fixed issue creating a lot of log spam (by @sophivorus) -* [793](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/793) Fixed issue with the slidesToShow paramter to the carousel format (by @thomas-topway-it) +* [793](https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/793) Fixed issue with the slidesToShow parameter to the carousel format (by @thomas-topway-it) * Further improvements and fixes to the Continuous Integration (CI) (by [gesinn.it](https://gesinn.it)) * Updated translations (by translatewiki.net community) diff --git a/Resources.php b/Resources.php index fbf86b647..2c782e2f9 100644 --- a/Resources.php +++ b/Resources.php @@ -569,7 +569,7 @@ // D3 'ext.d3.core' => $moduleTemplate + [ - 'scripts' => 'resources/jquery/d3/d3.v3.js' + 'scripts' => 'resources/jquery/d3/d3.min.js' ], // @@ -780,7 +780,8 @@ ], 'dependencies' => [ 'ext.srf.filtered.calendar-view.messages', - 'ext.jquery.fullcalendar' + // included using gulp + // 'ext.jquery.fullcalendar' ], ], diff --git a/SemanticResultFormats.hooks.php b/SemanticResultFormats.hooks.php index 1494d9308..4bb74e615 100644 --- a/SemanticResultFormats.hooks.php +++ b/SemanticResultFormats.hooks.php @@ -94,6 +94,7 @@ public static function addToAdminLinks( ALTree &$admin_links_tree ) { * @return true */ public static function onResourceLoaderGetConfigVars( &$vars ) { + // Powers srf.settings.get(), via ext.stf.js. $vars['srf-config'] = [ 'version' => SRF_VERSION, 'settings' => [ diff --git a/SemanticResultFormats.utils.php b/SemanticResultFormats.utils.php index 8a51c4db4..5328a639f 100644 --- a/SemanticResultFormats.utils.php +++ b/SemanticResultFormats.utils.php @@ -27,21 +27,6 @@ public static function htmlProcessingElement( $isHtml = true ) { ); } - /** - * Add JavaScript variables to the output - * - * @since 1.8 - */ - public static function addGlobalJSVariables() { - $options = [ - 'srfgScriptPath' => $GLOBALS['srfgScriptPath'], - 'srfVersion' => SRF_VERSION - ]; - - $requireHeadItem = [ 'srf.options' => $options ]; - SMWOutputs::requireHeadItem( 'srf.options', self::makeVariablesScript( $requireHeadItem ) ); - } - /** * Returns semantic search link for the current query * diff --git a/build b/build index 13d00b11a..3c2344c63 160000 --- a/build +++ b/build @@ -1 +1 @@ -Subproject commit 13d00b11a7743cb6a524c6ed577b8dea3913a62c +Subproject commit 3c2344c631a9023b228fd9970c8daf0583b333e1 diff --git a/composer.json b/composer.json index af1a0060e..31b3eaef2 100644 --- a/composer.json +++ b/composer.json @@ -44,9 +44,9 @@ "source": "https://github.com/SemanticMediaWiki/SemanticResultFormats" }, "require": { - "php": ">=7.3", + "php": ">=8.1", "composer/installers": ">=1.0.1", - "nicmart/tree": "^0.2.7", + "nicmart/tree": "^0.4.0", "data-values/geo": "~4.0|~3.0|~2.0" }, "suggest": { diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index d674a454c..12ebf0168 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -17,19 +17,26 @@ minimum requirements are indicated in bold. Semantic MediaWiki Release status + + 5.1.x + 8.1 - 8.4 + 1.39 - 1.44 + 5.0 - 6.x + Stable release + 5.0.x - 8.0 - 8.3+ - 1.39 - 1.43+ - 4.0.0 - 4.2+ - Future release + 8.1 - 8.4 + 1.39 - 1.43 + 5.0 + Obsolete release, no support 4.2.x - 7.3 - 8.1 - 1.35 - 1.39+ - 3.2.x - 4.2+ - Stable release + 7.3 - 8.2 + 1.35 - 1.39 + 3.2.x - 4.2 + Obsolete release, no support 4.1.x diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ec111501c..203b725ed 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -20,7 +20,7 @@ create one and add the following content to it: ``` { "require": { - "mediawiki/semantic-result-formats": "~4.2" + "mediawiki/semantic-result-formats": "~5.0" } } ``` @@ -28,7 +28,7 @@ create one and add the following content to it: If you already have a "composer.local.json" file add the following line to the end of the "require" section in your file: - "mediawiki/semantic-result-formats": "~4.2" + "mediawiki/semantic-result-formats": "~5.0" Remember to add a comma to the end of the preceding line in this section. diff --git a/docs/README.md b/docs/README.md index 898bcf8e9..ecc13d44d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ ## Technical documentation * [Filtered format](https://github.com/SemanticMediaWiki/SemanticResultFormats/blob/master/formats/filtered/README.md) +* [D3chart format](https://github.com/SemanticMediaWiki/SemanticResultFormats/blob/master/formats/d3/README.md) ## Repository documentation diff --git a/extension.json b/extension.json index a83960540..a424484af 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "SemanticResultFormats", - "version": "5.0.0-alpha", + "version": "5.1.0", "author": [ "James Hong Kong", "Stephan Gambke", @@ -38,22 +38,22 @@ "SRF\\Tests\\": "tests/phpunit/" }, "QUnitTestModule": { - "localBasePath": "", - "remoteExtPath": "SemanticResultFormats", + "localBasePath": "tests/qunit/", + "remoteExtPath": "SemanticResultFormats/tests/qunit/", "scripts": [ - "tests/qunit/ext.srf.test.js", - "tests/qunit/ext.srf.util.test.js", - "tests/qunit/formats/ext.srf.formats.datatables.test.js", - "tests/qunit/formats/ext.srf.formats.carousel.test.js", - "tests/qunit/formats/ext.srf.formats.eventcalendar.tests.js", - "tests/qunit/formats/ext.srf.formats.filtered.test.js", - "tests/qunit/formats/ext.srf.formats.gallery.test.js", - "tests/qunit/formats/ext.srf.formats.media.test.js", - "tests/qunit/formats/ext.srf.formats.tagcloud.test.js", - "tests/qunit/widgets/ext.srf.widgets.eventcalendar.tests.js", - "tests/qunit/widgets/ext.srf.widgets.optionslist.test.js", - "tests/qunit/widgets/ext.srf.widgets.panel.test.js", - "tests/qunit/widgets/ext.srf.widgets.parameters.test.js" + "ext.srf.test.js", + "ext.srf.util.test.js", + "formats/ext.srf.formats.datatables.test.js", + "formats/ext.srf.formats.carousel.test.js", + "formats/ext.srf.formats.eventcalendar.tests.js", + "formats/ext.srf.formats.filtered.test.js", + "formats/ext.srf.formats.gallery.test.js", + "formats/ext.srf.formats.media.test.js", + "formats/ext.srf.formats.tagcloud.test.js", + "widgets/ext.srf.widgets.eventcalendar.tests.js", + "widgets/ext.srf.widgets.optionslist.test.js", + "widgets/ext.srf.widgets.panel.test.js", + "widgets/ext.srf.widgets.parameters.test.js" ], "dependencies": [ "ext.srf", diff --git a/formats/Prolog/PrologPrinter.php b/formats/Prolog/PrologPrinter.php index 9c8a121d0..ab8c9fdbc 100644 --- a/formats/Prolog/PrologPrinter.php +++ b/formats/Prolog/PrologPrinter.php @@ -36,7 +36,7 @@ class PrologPrinter extends FileExportPrinter { * {@inheritDoc} */ public function getName() { - return $this->msg( 'srf-printername-prolog' ); + return $this->msg( 'srf-printername-prolog' )->text(); } /** diff --git a/formats/calendar/SRF_Calendar.php b/formats/calendar/SRF_Calendar.php index 6f80a4271..b8f634787 100644 --- a/formats/calendar/SRF_Calendar.php +++ b/formats/calendar/SRF_Calendar.php @@ -393,7 +393,7 @@ public function displayCalendar( $events ) { $lastDayOfWeek = 7; } else { $firstDayOfWeek = - array_search( $srfgFirstDayOfWeek, $weekDayNames ); + array_search( wfMessage( $srfgFirstDayOfWeek )->text(), $weekDayNames ); if ( $firstDayOfWeek === false ) { // Bad value for $srfgFirstDayOfWeek! print 'Warning: Bad value for $srfgFirstDayOfWeek "' . diff --git a/formats/d3/README.md b/formats/d3/README.md new file mode 100644 index 000000000..d6f5d2905 --- /dev/null +++ b/formats/d3/README.md @@ -0,0 +1,42 @@ +# d3chart + +## Updating `d3chart` Library in `SemanticResultFormats` + +The version is managed via `npm` and bundled into the extension using a script. + +### How It Works + +After running `npm install`, the following happens automatically: + +1. **Copying `d3.min.js`** + + A custom script (`copy-and-minify-d3.js`) copies the `d3.min.js` file from: + ``` + node_modules/d3 + ``` + + to: + + ``` + resources/jquery/d3/d3.min.js + ``` + +### To Update `d3chart` + +1. Open `package.json` and change the version under `"d3"` + + ``` + "dependencies": { + "d3": "3.0.4" + } + ``` +2. Run: + + ```bash + npm install + ``` + +#### This will: + +* Download the new version of D3 +* Automatically copy the updated `d3.min.js` to the expected location via the script \ No newline at end of file diff --git a/formats/d3/resources/ext.srf.d3.chart.bubble.js b/formats/d3/resources/ext.srf.d3.chart.bubble.js index cfde3de79..8ca13ce75 100644 --- a/formats/d3/resources/ext.srf.d3.chart.bubble.js +++ b/formats/d3/resources/ext.srf.d3.chart.bubble.js @@ -1,14 +1,16 @@ /** - * JavaScript for SRF D3 chart bubble module using d3 v2 + * JavaScript for SRF D3 chart bubble module + * Supports D3 v3 and v4+ (auto-detects version) + * * @see http://www.semantic-mediawiki.org/wiki/Help:D3chart format * * @since 1.8 * @release 0.3 * - * @licence GPL-2.0-or-later + * @license GPL-2.0-or-later * @author mwjames */ -( function( $, srf ) { +(function($, srf) { 'use strict'; /*global d3:true, mw:true, colorscheme:true*/ @@ -27,82 +29,146 @@ srf.formats.d3 = function() {}; srf.formats.d3.prototype = { - bubble: function( context ) { - return context.each( function() { - var width = $( this ).width(), - height = $( this ).height(), - chart = $( this ).find( '.container' ), - d3ID = chart.attr( 'id' ), - json = mw.config.get( d3ID ); - - // Parse json string and convert it back - var container = typeof json === 'string' ? jQuery.parseJSON( json ) : json; - - var charttitle = container.parameters.charttitle, - charttext = container.parameters.charttext, - datalabels = container.parameters.datalabels, - colors = container.parameters.colorscheme === null ? colorscheme[0] : colorscheme[container.parameters.colorscheme][9]; - - // Release the graph - util.spinner.hide( { context: $( this ) } ); - $( this ).css( 'width', width ).css( 'height', height); + bubble: function(context) { + return context.each(function() { + var width = $(this).width(), + height = $(this).height(), + chart = $(this).find('.container'), + d3ID = chart.attr('id'), + json = mw.config.get(d3ID); + + var container = typeof json === 'string' ? jQuery.parseJSON(json) : json; + + var charttitle = container.parameters.charttitle || '', + charttext = container.parameters.charttext || '', + datalabels = container.parameters.datalabels, + colors; + + if (!container.parameters.colorscheme || typeof colorscheme[container.parameters.colorscheme] === 'undefined') { + colors = colorscheme[0]; + } else { + colors = colorscheme[container.parameters.colorscheme]; + } + + // Hide spinner, set dimensions, show chart container + util.spinner.hide({ context: $(this) }); + $(this).css('width', width).css('height', height); chart.show(); - // Add chart title - if ( charttitle.length > 0 ) { - charttitle = '' + charttitle + ''; - $( this ).find( '#' + d3ID ).before( charttitle ); + // Add chart title if set + if (charttitle.length > 0) { + var titleHTML = '' + charttitle + ''; + $(this).find('#' + d3ID).before(titleHTML); } - // Add bottom chart text - if ( charttext.length > 0 ) { - charttext = '' + charttext + ''; - $( this ).find( '#' + d3ID ).after( charttext ); + // Add chart text if set + if (charttext.length > 0) { + var textHTML = '' + charttext + ''; + $(this).find('#' + d3ID).after(textHTML); } - // Calculate height - height = height - ( $( this ).find( '.srf-d3-chart-title' ).height() + $( this ).find( '.srf-d3-chart-text' ).height() ); + // Adjust height by subtracting title and text heights + var titleHeight = $(this).find('.srf-d3-chart-title').height() || 0; + var textHeight = $(this).find('.srf-d3-chart-text').height() || 0; + height = height - (titleHeight + textHeight); + if (isNaN(height) || height < 0) height = 0; + + // Detect if using D3 v4+ by checking for d3.pack + var isV4Plus = typeof d3.pack === "function"; - // Create an ordinal color array and set formatting - var color = d3.scale.ordinal().range( colors ), - format = d3.format( ",d" ); + // Color scale and format function + var color = isV4Plus + ? d3.scaleOrdinal().range(colors) + : d3.scale.ordinal().range(colors); - // Data array definition - var packlayout = []; - packlayout.push( { - label: charttitle !== '' ? container.parameters.charttitle : mw.config.get ( 'wgTitle' ), + var format = d3.format(",d"); + + // Data root object + var packlayout = { + label: charttitle !== '' ? charttitle : mw.config.get('wgTitle'), children: container.data - } ); - - var pack = d3.layout.pack() - .size([width - 4, height - 4]) - .value( function( d ) { return d.value; } ); - - var vis = d3.select( "#" + d3ID ).append( "svg" ) - .attr( "width", width ) - .attr( "height", height ) - .attr( "class", "pack" ) - .append( "g" ) - .attr( "transform", "translate(2, 2)" ); - - var node = vis.data(packlayout).selectAll("g.node") - .data( pack.nodes ) - .enter().append("g") - .attr( "class", function( d ) { return d.children ? "node" : "leaf node"; } ) - .attr( "transform", function( d ) { return "translate(" + d.x + "," + d.y + ")"; } ); - - node.append("title") - .text(function( d ) { return d.label + ( d.children ? "" : ": " + format( d.value ) ); } ); - - node.append( "circle" ) - .attr( "r" , function( d ) { return d.r; } ) - .style( "fill" , function( d ) { return d.children ? null : color( d.label ); } ); - - node.filter(function( d ) { return !d.children; } ).append("text") - .attr( "text-anchor", "middle" ) - .attr( "dy", ".3em" ) - .text( function( d ) { return d.children ? null : datalabels === 'value' ? d.value : d.label.substring( 0, d.r / 3 ); } ); - } ); + }; + + // Select or create SVG element + var svg = d3.select("#" + d3ID).select("svg"); + if (svg.empty()) { + svg = d3.select("#" + d3ID).append("svg") + .attr("width", width) + .attr("height", height) + .attr("class", "pack"); + } else { + svg.selectAll("*").remove(); + svg.attr("width", width).attr("height", height); + } + + var vis = svg.append("g").attr("transform", "translate(2,2)"); + + if (isV4Plus) { + // D3 v4+ usage + var pack = d3.pack() + .size([width - 4, height - 4]) + .padding(1); + + var root = d3.hierarchy(packlayout) + .sum(function(d) { return d.value; }) + .sort(function(a, b) { return b.value - a.value; }); + + pack(root); + + var node = vis.selectAll("g.node") + .data(root.descendants()) + .enter().append("g") + .attr("class", function(d) { return d.children ? "node" : "leaf node"; }) + .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; }); + + node.append("title") + .text(function(d) { return d.data.label + (d.children ? "" : ": " + format(d.value)); }); + + node.append("circle") + .attr("r", function(d) { return d.r; }) + .style("fill", function(d) { return d.children ? null : color(d.data.label); }); + + node.filter(function(d) { return !d.children; }) + .append("text") + .attr("text-anchor", "middle") + .attr("dy", ".3em") + .text(function(d) { + if (d.children) return null; + if (datalabels === 'value') return d.value; + return d.data.label.substring(0, d.r / 3); + }); + } else { + // D3 v3 or lower usage + var pack = d3.layout.pack() + .size([width - 4, height - 4]) + .value(function(d) { return d.value; }); + + var nodes = pack.nodes(packlayout); + + var node = vis.selectAll("g.node") + .data(nodes) + .enter().append("g") + .attr("class", function(d) { return d.children ? "node" : "leaf node"; }) + .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; }); + + node.append("title") + .text(function(d) { return d.label + (d.children ? "" : ": " + format(d.value)); }); + + node.append("circle") + .attr("r", function(d) { return d.r; }) + .style("fill", function(d) { return d.children ? null : color(d.label); }); + + node.filter(function(d) { return !d.children; }) + .append("text") + .attr("text-anchor", "middle") + .attr("dy", ".3em") + .text(function(d) { + if (d.children) return null; + if (datalabels === 'value') return d.value; + return d.label.substring(0, d.r / 3); + }); + } + }); } }; @@ -115,8 +181,8 @@ var util = new srf.util(); $(document).ready(function() { - $( '.srf-d3-chart-bubble' ).each(function() { - srfD3.bubble( $( this ) ); - } ); - } ); -} )( jQuery, semanticFormats ); \ No newline at end of file + $('.srf-d3-chart-bubble').each(function() { + srfD3.bubble($(this)); + }); + }); +})(jQuery, semanticFormats); diff --git a/formats/d3/resources/ext.srf.d3.chart.treemap.js b/formats/d3/resources/ext.srf.d3.chart.treemap.js index 5165aadf3..4673c54ea 100644 --- a/formats/d3/resources/ext.srf.d3.chart.treemap.js +++ b/formats/d3/resources/ext.srf.d3.chart.treemap.js @@ -1,14 +1,14 @@ /** - * JavaScript for SRF D3 chart treemap module using d3 v2 + * JavaScript for SRF D3 chart treemap module supporting d3 v3 and v4+ * @see http://www.semantic-mediawiki.org/wiki/Help:D3chart format * * @since 1.8 * @release 0.3 * - * @licence GPL-2.0-or-later + * @license GPL-2.0-or-later * @author mwjames */ -( function( $, srf ) { +(function($, srf) { 'use strict'; /*global d3:true, mw:true, colorscheme:true*/ @@ -28,100 +28,157 @@ srf.formats.d3 = function() {}; srf.formats.d3.prototype = { - treemap: function( context ) { - return context.each( function() { - var width = $( this ).width(), - height = $( this ).height(), - chart = $( this ).find( '.container' ), - d3ID = chart.attr( 'id' ), - json = mw.config.get( d3ID ); - - // Parse json string and convert it back - var container = typeof json === 'string' ? jQuery.parseJSON( json ) : json; - - var charttitle = container.parameters.charttitle, - charttext = container.parameters.charttext, - datalabels = container.parameters.datalabels, - colors = container.parameters.colorscheme === null ? colorscheme[0] : colorscheme[container.parameters.colorscheme][9]; - - // Release the graph - util.spinner.hide( { context: $( this ) } ); - $( this ).css( 'width', width ).css( 'height', height); + treemap: function(context) { + return context.each(function() { + var width = $(this).width(), + height = $(this).height(), + chart = $(this).find('.container'), + d3ID = chart.attr('id'), + json = mw.config.get(d3ID); + + // Parse JSON string if necessary + var container = typeof json === 'string' ? jQuery.parseJSON(json) : json; + + var charttitle = container.parameters.charttitle || '', + charttext = container.parameters.charttext || '', + datalabels = container.parameters.datalabels, + colors; + + if (!container.parameters.colorscheme || typeof colorscheme[container.parameters.colorscheme] === 'undefined') { + colors = colorscheme[0]; + } else { + colors = colorscheme[container.parameters.colorscheme]; + } + + // Hide spinner, set dimensions, show chart container + util.spinner.hide({ context: $(this) }); + $(this).css('width', width).css('height', height); chart.show(); - // Add chart title - if ( charttitle.length > 0 ) { - charttitle = '' + charttitle + ''; - $( this ).find( '#' + d3ID ).before( charttitle ); + // Add chart title if set + if (charttitle.length > 0) { + var titleHTML = '' + charttitle + ''; + $(this).find('#' + d3ID).before(titleHTML); } - // Add bottom chart text - if ( charttext.length > 0 ) { - charttext = '' + charttext + ''; - $( this ).find( '#' + d3ID ).after( charttext ); + // Add chart text if set + if (charttext.length > 0) { + var textHTML = '' + charttext + ''; + $(this).find('#' + d3ID).after(textHTML); } - // Calculate height - height = height - ( $( this ).find( '.srf-d3-chart-title' ).height() + $( this ).find( '.srf-d3-chart-text' ).height() ); + // Adjust height by subtracting title and text heights + var titleHeight = $(this).find('.srf-d3-chart-title').height() || 0; + var textHeight = $(this).find('.srf-d3-chart-text').height() || 0; + height = height - (titleHeight + textHeight); + if (isNaN(height) || height < 0) height = 0; + + // Detect if using D3 v4+ by checking for d3.pack + var isV4Plus = typeof d3.pack === "function"; - // Create an ordinal color array and set formatting - var color = d3.scale.ordinal().range( colors ), - format = d3.format( ',d' ); + // Color scale + var color = isV4Plus + ? d3.scaleOrdinal().range(colors) + : d3.scale.ordinal().range(colors); - // Data array definition - var treeArray = []; - treeArray.push( { - label: charttitle !== '' ? container.parameters.charttitle : mw.config.get ( 'wgTitle' ), + var format = d3.format(",d"); + + // Root data object + var treeData = { + label: charttitle !== '' ? charttitle : mw.config.get('wgTitle'), children: container.data - } ); - - // Init layout - var treemap = d3.layout.treemap() - .padding( 4 ) - .size([ width , height ]) - .value( function( d ) { return d.value; } ); - - var svg = d3.select( '#' + d3ID ).append( 'svg' ) - .attr( 'width', width ) - .attr( 'height', height ) - .append( 'g' ) - .attr( 'transform', 'translate(-.5,-.5)' ); - - var cell = svg.data( treeArray ).selectAll( 'g' ) - .data( treemap ) - .enter().append( 'g' ) - .attr( 'class', 'cell' ) - .attr( 'transform', function( d ) { return 'translate(' + d.x + ',' + d.y + ')'; } ); - - cell.append( 'title' ) - .text( function( d ) { return d.label + ( d.children ? '' : ': ' + format( d.value ) ); } ); - - cell.append( 'rect' ) - .attr( 'width', function( d ) { return d.dx; } ) - .attr( 'height', function( d ) { return d.dy; } ) - .style( 'fill', function( d ) { return d.label ? color( d.label ) : color( d.label ); } ); - - cell.append( 'text' ) - .attr( 'x', function( d ) { return d.dx / 2; } ) - .attr( 'y', function( d ) { return d.dy / 2; } ) - .attr( 'dy', '.35em' ) - .attr( 'text-anchor', 'middle' ) - .text( function( d ) { return d.children ? null : datalabels === 'value' ? d.value : d.label ; } ); - } ); + }; + + // Select or create SVG container + var svg = d3.select("#" + d3ID).select("svg"); + if (svg.empty()) { + svg = d3.select("#" + d3ID).append("svg") + .attr("width", width) + .attr("height", height); + } else { + svg.selectAll("*").remove(); + svg.attr("width", width).attr("height", height); + } + + if (isV4Plus) { + // D3 v4+ usage + var root = d3.hierarchy(treeData) + .sum(function(d) { return d.value; }) + .sort(function(a, b) { return b.value - a.value; }); + + var treemap = d3.treemap() + .size([width, height]) + .padding(4); + + treemap(root); + + var cell = svg.selectAll("g") + .data(root.leaves()) + .enter().append("g") + .attr("class", "cell") + .attr("transform", function(d) { return "translate(" + d.x0 + "," + d.y0 + ")"; }); + + cell.append("title") + .text(function(d) { return d.data.label + ": " + format(d.value); }); + + cell.append("rect") + .attr("width", function(d) { return d.x1 - d.x0; }) + .attr("height", function(d) { return d.y1 - d.y0; }) + .style("fill", function(d) { return color(d.data.label); }); + + cell.append("text") + .attr("x", function(d) { return (d.x1 - d.x0) / 2; }) + .attr("y", function(d) { return (d.y1 - d.y0) / 2; }) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .text(function(d) { + if (datalabels === 'value') return d.value; + return d.data.label; + }); + } else { + // D3 v3 or lower usage + var treemap = d3.layout.treemap() + .size([width, height]) + .padding(4) + .value(function(d) { return d.value; }); + + var nodes = treemap.nodes(treeData); + + var cell = svg.selectAll("g") + .data(nodes) + .enter().append("g") + .attr("class", "cell") + .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; }); + + cell.append("title") + .text(function(d) { return d.label + (d.children ? "" : ": " + format(d.value)); }); + + cell.append("rect") + .attr("width", function(d) { return d.dx; }) + .attr("height", function(d) { return d.dy; }) + .style("fill", function(d) { return d.label ? color(d.label) : color(d.label); }); + + cell.append("text") + .attr("x", function(d) { return d.dx / 2; }) + .attr("y", function(d) { return d.dy / 2; }) + .attr("dy", ".35em") + .attr("text-anchor", "middle") + .text(function(d) { + if (d.children) return null; + if (datalabels === 'value') return d.value; + return d.label; + }); + } + }); } }; - /** - * Implementation and representation of the d3 treemap instance - * @since 1.8 - * @type Object - */ var srfD3 = new srf.formats.d3(); var util = new srf.util(); $(document).ready(function() { - $( '.srf-d3-chart-treemap' ).each(function() { - srfD3.treemap( $( this ) ); - } ); - } ); -} )( jQuery, semanticFormats ); \ No newline at end of file + $('.srf-d3-chart-treemap').each(function() { + srfD3.treemap($(this)); + }); + }); +})(jQuery, semanticFormats); diff --git a/formats/dataframe/DataframePrinter.php b/formats/dataframe/DataframePrinter.php index 4eec11cfd..96d1995ab 100644 --- a/formats/dataframe/DataframePrinter.php +++ b/formats/dataframe/DataframePrinter.php @@ -32,7 +32,7 @@ class DataframePrinter extends FileExportPrinter { * {@inheritDoc} */ public function getName() { - return $this->msg( 'srf-printername-dataframe' ); + return $this->msg( 'srf-printername-dataframe' )->text(); } /** diff --git a/formats/datatables/DataTables.php b/formats/datatables/DataTables.php index a15796a52..12632623f 100644 --- a/formats/datatables/DataTables.php +++ b/formats/datatables/DataTables.php @@ -13,6 +13,9 @@ namespace SRF; use Html; +use MediaWiki\MediaWikiServices; +use Mediawiki\Title\Title; +use MWException; use Parser; use RequestContext; use SMW\DataValues\PropertyValue; @@ -419,9 +422,13 @@ protected function buildResult( QueryResult $results ): array { $this->isHTML = true; $this->hasTemplates = false; - $this->parser = $this->copyParser(); + $outputMode = ( $this->params['apicall'] !== 'apicall' ? SMW_OUTPUT_HTML : $this->outputMode ); - $outputMode = ( $this->params['apicall'] !== "apicall" ? SMW_OUTPUT_HTML : $this->outputMode ); + if ( $this->params['apicall'] === 'apicall' ) { + $this->initializePrintoutParametersAndParser( $results ); + } else { + $this->parser = $this->copyParser(); + } // Get output from printer: $result = $this->getResultText( $results, $outputMode ); @@ -435,6 +442,33 @@ protected function buildResult( QueryResult $results ): array { return $result; } + /** + * @param QueryResult $results + */ + protected function initializePrintoutParametersAndParser( QueryResult $results ) { + // rebuild $this->printoutsParameters from + // printouts since $this->getPrintouts is not invoked + // alternatively use the $data['printouts'] from the Api + $printRequests = $results->getPrintRequests(); + foreach ( $printRequests as $printRequest ) { + $canonicalLabel = $printRequest->getCanonicalLabel(); + $this->printoutsParameters[$canonicalLabel] = $printRequest->getParameters(); + } + + // @see https://github.com/SemanticMediaWiki/SemanticResultFormats/pull/854 + // the following ensures that $this->parser->recursiveTagParseFully + // (getCellContent) will work + $context = RequestContext::getMain(); + $performer = $context->getUser(); + $output = $context->getOutput(); + + $this->parser = MediaWikiServices::getInstance()->getParserFactory()->getInstance(); + $this->parser->setTitle( $output->getTitle() ); + $this->parser->setOptions( $output->parserOptions() ); + $this->parser->setOutputType( Parser::OT_HTML ); + $this->parser->clearState(); + } + /** * {@inheritDoc} */ @@ -462,6 +496,8 @@ protected function handleNonFileResult( $result, QueryResult $results, $outputmo // Apply intro parameter if ( ( $this->mIntro ) && ( $results->getCount() > 0 ) ) { + // @see https://github.com/SemanticMediaWiki/SemanticResultFormats/issues/853 + // $result = $this->parser->recursiveTagParseFully( $this->mIntro ) . $result; if ( $outputmode == SMW_OUTPUT_HTML && $this->isHTML ) { $result = Message::get( [ 'smw-parse', $this->mIntro ], Message::PARSE ) . $result; } elseif ( $outputmode !== SMW_OUTPUT_RAW ) { @@ -471,6 +507,8 @@ protected function handleNonFileResult( $result, QueryResult $results, $outputmo // Apply outro parameter if ( ( $this->mOutro ) && ( $results->getCount() > 0 ) ) { + // @see https://github.com/SemanticMediaWiki/SemanticResultFormats/issues/853 + // $result = $result . $this->parser->recursiveTagParseFully( $this->mOutro ); if ( $outputmode == SMW_OUTPUT_HTML && $this->isHTML ) { $result = $result . Message::get( [ 'smw-parse', $this->mOutro ], Message::PARSE ); } elseif ( $outputmode !== SMW_OUTPUT_RAW ) { @@ -878,7 +916,7 @@ private function getResultJson( QueryResult $res, int $outputMode ): array { /** * @see SMW\Query\ResultPrinters\TableResultPrinter */ - private function getCellContent( + public function getCellContent( string $label, array $dataValues, int $outputMode, @@ -931,9 +969,13 @@ private function getCellContent( } if ( $template ) { - // escape pipe character - $value_ = str_replace( '|', '|', (string)$value ); - $value = $this->parser->recursiveTagParseFully( '{{' . $template . '|' . $value_ . '}}' ); + // @fixme use named parameter ? + $titleTemplate = Title::makeTitle( NS_TEMPLATE, + Title::capitalize( trim( $template ), NS_TEMPLATE ) ); + $value_ = $this->expandTemplate( $titleTemplate, [ 1 => $value ] ); + $value = Parser::stripOuterParagraph( + $this->parser->recursiveTagParseFully( $value_ ) + ); } $values[] = $value === '' ? ' ' : $value; @@ -966,6 +1008,38 @@ private function getCellContent( ]; } + /** + * @see https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/VisualData/+/refs/heads/master/includes/classes/ResultPrinter.php + * @param Title|Mediawiki\Title\Title $title + * @param array $args + * @return array + */ + public function expandTemplate( $title, $args ) { + $titleText = $title->getText(); + $frame = $this->parser->getPreprocessor()->newFrame(); + + if ( $frame->depth >= $this->parser->getOptions()->getMaxTemplateDepth() ) { + throw new MWException( 'expandTemplate: template depth limit exceeded' ); + } + + if ( MediaWikiServices::getInstance()->getNamespaceInfo()->isNonincludable( $title->getNamespace() ) ) { + throw new MWException( 'expandTemplate: template inclusion denied' ); + } + + [ $dom, $finalTitle ] = $this->parser->getTemplateDom( $title ); + if ( $dom === false ) { + throw new MWException( "expandTemplate: template \"$titleText\" does not exist" ); + } + + if ( !$frame->loopCheck( $finalTitle ) ) { + throw new MWException( 'expandTemplate: template loop detected' ); + } + + $fargs = $this->parser->getPreprocessor()->newPartNodeArray( $args ); + $newFrame = $frame->newChild( $fargs, $finalTitle ); + return $newFrame->expand( $dom ); + } + /** * {@inheritDoc} */ diff --git a/formats/datatables/QuerySegmentListProcessor.php b/formats/datatables/QuerySegmentListProcessor.php new file mode 100644 index 000000000..1ca726f57 --- /dev/null +++ b/formats/datatables/QuerySegmentListProcessor.php @@ -0,0 +1,390 @@ +joinConditions and $this->fromTables + * to the original QuerySegmentListProcessor for the + * use with SearchPanes + */ +namespace SRF\DataTables; + +use RuntimeException; +use SMW\MediaWiki\Database; +use SMW\SQLStore\QueryEngine\QuerySegment; +use SMW\SQLStore\TableBuilder\TemporaryTableBuilder; +use SMWQuery as Query; + +class QuerySegmentListProcessor { + /* @var array */ + public $joinConditions = []; + + /* @var array */ + public $fromTables = []; + + /** + * @var Database + */ + private $connection; + + /** + * @var TemporaryTableBuilder + */ + private $temporaryTableBuilder; + + /** + * @var HierarchyTempTableBuilder + */ + private $hierarchyTempTableBuilder; + + /** + * Array of arrays of executed queries, indexed by the temporary table names + * results were fed into. + * + * @var array + */ + private $executedQueries = []; + + /** + * Query mode copied from given query. Some submethods act differently when + * in Query::MODE_DEBUG. + * + * @var int + */ + private $queryMode; + + /** + * @var array + */ + private $querySegmentList = []; + + /** + * @param Database $connection + * @param TemporaryTableBuilder $temporaryTableBuilder + * @param HierarchyTempTableBuilder $hierarchyTempTableBuilder + */ + public function __construct( $connection, TemporaryTableBuilder $temporaryTableBuilder, $hierarchyTempTableBuilder ) { + $this->connection = $connection; + $this->temporaryTableBuilder = $temporaryTableBuilder; + $this->hierarchyTempTableBuilder = $hierarchyTempTableBuilder; + } + + /** + * @since 2.2 + * + * @return array + */ + public function getExecutedQueries() { + return $this->executedQueries; + } + + /** + * @since 2.2 + * + * @param &$querySegmentList + */ + public function setQuerySegmentList( &$querySegmentList ) { + $this->querySegmentList =& $querySegmentList; + } + + /** + * @since 2.2 + * + * @param int $queryMode + */ + public function setQueryMode( $queryMode ) { + $this->queryMode = $queryMode; + } + + /** + * Process stored queries and change store accordingly. The query obj is modified + * so that it contains non-recursive description of a select to execute for getting + * the actual result. + * + * @param int $id + * + * @throws RuntimeException + */ + public function process( $id ) { + $this->hierarchyTempTableBuilder->emptyHierarchyCache(); + $this->executedQueries = []; + + // Should never happen + if ( !isset( $this->querySegmentList[$id] ) ) { + throw new RuntimeException( "$id doesn't exist" ); + } + + $this->segment( $this->querySegmentList[$id] ); + } + + private function segment( QuerySegment &$query ) { + switch ( $query->type ) { + case QuerySegment::Q_TABLE: // . + $this->table( $query ); + break; + case QuerySegment::Q_CONJUNCTION: + $this->conjunction( $query ); + break; + case QuerySegment::Q_DISJUNCTION: + $this->disjunction( $query ); + break; + case QuerySegment::Q_PROP_HIERARCHY: + case QuerySegment::Q_CLASS_HIERARCHY: // make a saturated hierarchy + $this->hierarchy( $query ); + break; + case QuerySegment::Q_VALUE: + break; // nothing to do + } + } + + /** + * Resolves normal queries with possible conjunctive subconditions + */ + private function table( QuerySegment &$query ) { + foreach ( $query->components as $qid => $joinField ) { + $subQuery = $this->querySegmentList[$qid]; + $this->segment( $subQuery ); + $alias = $subQuery->alias; + + if ( $subQuery->joinTable !== '' ) { // Join with jointable.joinfield + $op = $subQuery->not ? '!' : ''; + + $joinType = $subQuery->joinType ? $subQuery->joinType : 'INNER'; + $t = $this->connection->tableName( $subQuery->joinTable ) . " AS $subQuery->alias"; + // If the alias is the same as the table name and if there is a prefix, MediaWiki does not declare the unprefixed alias + $joinTable = $subQuery->joinTable === $subQuery->alias ? $this->connection->tableName( $subQuery->joinTable ) : $subQuery->joinTable; + + if ( $subQuery->from ) { + $t = "($t $subQuery->from)"; + $alias = 'nested' . $subQuery->alias; + $query->fromTables[$alias] = array_merge( (array)$subQuery->fromTables, [ $subQuery->alias => $joinTable ] ); + $query->joinConditions = array_merge( (array)$query->joinConditions, (array)$subQuery->joinConditions ); + + } else { + $query->fromTables[$alias] = $joinTable; + } + + $query->joinConditions[$alias] = [ $joinType . ' JOIN', "$joinField$op=" . $subQuery->joinfield ]; + + $this->fromTables[$subQuery->alias] = $joinTable; + + ksort( $this->fromTables ); + $this->joinConditions[$subQuery->alias] = [ $joinType . ' JOIN', "$joinField$op=" . $subQuery->joinfield ]; + + $query->from .= " $joinType JOIN $t ON $joinField$op=" . $subQuery->joinfield; + if ( $joinType === 'LEFT' ) { + $query->where .= ( ( $query->where === '' ) ? '' : ' AND ' ) . '(' . $subQuery->joinfield . ' IS NULL)'; + } + + } elseif ( $subQuery->joinfield !== '' ) { // Require joinfield as "value" via WHERE. + $condition = ''; + + if ( $subQuery->null === true ) { + $condition .= ( $condition ? ' OR ' : '' ) . "$joinField IS NULL"; + } else { + foreach ( $subQuery->joinfield as $value ) { + $op = $subQuery->not ? '!' : ''; + $condition .= ( $condition ? ' OR ' : '' ) . "$joinField$op=" . $this->connection->addQuotes( $value ); + } + } + + if ( count( $subQuery->joinfield ) > 1 ) { + $condition = "($condition)"; + } + + $query->where .= ( ( $query->where === '' || $subQuery->where === null ) ? '' : ' AND ' ) . $condition; + $query->from .= $subQuery->from; + $query->fromTables = array_merge( (array)$query->fromTables, (array)$subQuery->fromTables ); + $query->joinConditions = array_merge( (array)$query->joinConditions, (array)$subQuery->joinConditions ); + } else { // interpret empty joinfields as impossible condition (empty result) + $query->joinfield = ''; // make whole query false + $query->joinTable = ''; + $query->where = ''; + $query->from = ''; + $query->fromTables = []; + $query->joinConditions = []; + break; + } + + if ( $subQuery->where !== '' && $subQuery->where !== null ) { + if ( $subQuery->joinType === 'LEFT' || $subQuery->joinType == 'LEFT OUTER' ) { + $query->from .= ' AND (' . $subQuery->where . ')'; + $query->joinConditions[$alias][1] .= ' AND (' . $subQuery->where . ')'; + } else { + $query->where .= ( ( $query->where === '' ) ? '' : ' AND ' ) . '(' . $subQuery->where . ')'; + } + } + } + + $query->components = []; + } + + private function conjunction( QuerySegment &$query ) { + reset( $query->components ); + $key = false; + + // Pick one subquery as anchor point ... + foreach ( $query->components as $qkey => $qid ) { + $key = $qkey; + + if ( $this->querySegmentList[$qkey]->joinTable !== '' ) { + break; + } + } + + $result = $this->querySegmentList[$key]; + unset( $query->components[$key] ); + + // Execute it first (may change jointable and joinfield, e.g. when + // making temporary tables) + $this->segment( $result ); + + // ... and append to this query the remaining queries. + foreach ( $query->components as $qid => $joinfield ) { + $result->components[$qid] = $result->joinfield; + } + + // Second execute, now incorporating remaining conditions. + $this->segment( $result ); + + $query = $result; + } + + private function disjunction( QuerySegment &$query ) { + if ( $this->queryMode !== Query::MODE_NONE ) { + $this->temporaryTableBuilder->create( $this->connection->tableName( $query->alias ) ); + } + + $this->executedQueries[$query->alias] = []; + + foreach ( $query->components as $qid => $joinField ) { + $subQuery = $this->querySegmentList[$qid]; + $this->segment( $subQuery ); + $sql = ''; + + if ( $subQuery->joinTable !== '' ) { + $sql = 'INSERT ' . 'IGNORE ' . 'INTO ' . + $this->connection->tableName( $query->alias ) . + " SELECT DISTINCT $subQuery->joinfield FROM " . $this->connection->tableName( $subQuery->joinTable ) . + " AS $subQuery->alias $subQuery->from" . ( $subQuery->where ? " WHERE $subQuery->where" : '' ); + } elseif ( $subQuery->joinfield !== '' ) { + // NOTE: this works only for single "unconditional" values without further + // WHERE or FROM. The execution must take care of not creating any others. + $values = ''; + + // This produces an error on postgres with + // pg_query(): Query failed: ERROR: duplicate key value violates + // unique constraint "sunittest_t3_pkey" DETAIL: Key (id)=(274) already exists. + + foreach ( $subQuery->joinfield as $value ) { + $values .= ( $values ? ',' : '' ) . '(' . $this->connection->addQuotes( $value ) . ')'; + } + + $sql = 'INSERT ' . 'IGNORE ' . 'INTO ' . $this->connection->tableName( $query->alias ) . " (id) VALUES $values"; + } // else: // interpret empty joinfields as impossible condition (empty result), ignore + + if ( $sql ) { + $this->executedQueries[$query->alias][] = $sql; + + if ( $this->queryMode !== Query::MODE_NONE ) { + $this->connection->query( + $sql, + __METHOD__, + ISQLPlatform::QUERY_CHANGE_ROWS + ); + } + } + } + + $query->type = QuerySegment::Q_TABLE; + $query->where = ''; + $query->components = []; + + $query->joinTable = $query->alias; + $query->joinfield = "$query->alias.id"; + $query->sortfields = []; // Make sure we got no sortfields. + + // TODO: currently this eliminates sortkeys, possibly keep them (needs + // different temp table format though, maybe not such a good thing to do) + } + + /** + * Find subproperties or subcategories. This may require iterative computation, + * and temporary tables are used in many cases. + * + * @param QuerySegment &$query + */ + private function hierarchy( QuerySegment &$query ) { + switch ( $query->type ) { + case QuerySegment::Q_PROP_HIERARCHY: + $type = 'property'; + break; + case QuerySegment::Q_CLASS_HIERARCHY: + $type = 'class'; + break; + } + + [ $smwtable, $depth ] = $this->hierarchyTempTableBuilder->getTableDefinitionByType( + $type + ); + + // An individual depth was annotated as part of the query + if ( $query->depth !== null ) { + $depth = $query->depth; + } + + if ( $depth <= 0 ) { // treat as value, no recursion + $query->type = QuerySegment::Q_VALUE; + return; + } + + $values = ''; + $valuecond = ''; + + foreach ( $query->joinfield as $value ) { + $values .= ( $values ? ',' : '' ) . '(' . $this->connection->addQuotes( $value ) . ')'; + $valuecond .= ( $valuecond ? ' OR ' : '' ) . 'o_id=' . $this->connection->addQuotes( $value ); + } + + // Try to safe time (SELECT is cheaper than creating/dropping 3 temp tables): + $res = $this->connection->select( + $smwtable, + 's_id', + $valuecond, + __METHOD__, + [ 'LIMIT' => 1 ] + ); + + if ( !$res->fetchObject() ) { // no subobjects, we are done! + $res->free(); + $query->type = QuerySegment::Q_VALUE; + return; + } + + $res->free(); + $tablename = $this->connection->tableName( $query->alias ); + $this->executedQueries[$query->alias] = [ + "Recursively computed hierarchy for element(s) $values.", + "SELECT s_id FROM $smwtable WHERE $valuecond LIMIT 1" + ]; + + $query->joinTable = $query->alias; + $query->joinfield = "$query->alias.id"; + + $this->hierarchyTempTableBuilder->fillTempTable( + $type, + $tablename, + $values, + $depth + ); + } + + /** + * After querying, make sure no temporary database tables are left. + * + * @todo I might be better to keep the tables and possibly reuse them later + * on. Being temporary, the tables will vanish with the session anyway. + */ + public function cleanUp() { + foreach ( $this->executedQueries as $table => $log ) { + $this->temporaryTableBuilder->drop( $this->connection->tableName( $table ) ); + } + } +} diff --git a/formats/datatables/SearchPanes.php b/formats/datatables/SearchPanes.php index 9c8b47c85..4cdfba65b 100644 --- a/formats/datatables/SearchPanes.php +++ b/formats/datatables/SearchPanes.php @@ -17,15 +17,18 @@ use SMW\DIWikiPage; use SMW\Query\PrintRequest; use SMW\Services\ServicesFactory as ApplicationFactory; +use SMW\SQLStore\QueryEngine\HierarchyTempTableBuilder; use SMW\SQLStore\QueryEngine\QuerySegment; use SMW\SQLStore\QueryEngineFactory; use SMW\SQLStore\SQLStore; use SMW\SQLStore\TableBuilder\FieldType; -use SMWDataItem as DataItem; +use SMW\SQLStore\TableBuilder\TemporaryTableBuilder; use SMWQueryProcessor; use SRF\DataTables; class SearchPanes { + /** @var DataTables */ + private $datatables; private array $searchPanesLog = []; @@ -33,9 +36,58 @@ class SearchPanes { private $connection; - public function __construct( - private DataTables $datatables - ) { + public function __construct( DataTables $datatables ) { + $this->datatables = $datatables; + } + + private function newTemporaryTableBuilder() { + $temporaryTableBuilder = new TemporaryTableBuilder( + $this->datatables->store->getConnection( 'mw.db.queryengine' ) + ); + + $temporaryTableBuilder->setAutoCommitFlag( + ApplicationFactory::getInstance()->getSettings()->get( 'smwgQTemporaryTablesAutoCommitMode' ) + ); + + return $temporaryTableBuilder; + } + + /** + * @see SMW\SQLStore\QueryEngineFactory + * @return QuerySegmentListProcessor + */ + public function newQuerySegmentListProcessor() { + $settings = ApplicationFactory::getInstance()->getSettings(); + + $connection = $this->datatables->store->getConnection( 'mw.db.queryengine' ); + $temporaryTableBuilder = $this->newTemporaryTableBuilder(); + + $hierarchyTempTableBuilder = new HierarchyTempTableBuilder( + $connection, + $temporaryTableBuilder + ); + + $hierarchyTempTableBuilder->setTableDefinitions( + [ + 'property' => [ + 'table' => $this->datatables->store->findPropertyTableID( new DIProperty( '_SUBP' ) ), + 'depth' => $settings->get( 'smwgQSubpropertyDepth' ) + ], + 'class' => [ + 'table' => $this->datatables->store->findPropertyTableID( new DIProperty( '_SUBC' ) ), + 'depth' => $settings->get( 'smwgQSubcategoryDepth' ) + ] + + ] + ); + + $querySegmentListProcessor = new QuerySegmentListProcessor( + $connection, + $temporaryTableBuilder, + $hierarchyTempTableBuilder + ); + + return $querySegmentListProcessor; } public function getSearchPanes( array $printRequests, array $searchPanesOptions ): array { @@ -117,7 +169,8 @@ private function getPanesOptions( QuerySegment::$qnum = 0; $querySegmentList = $conditionBuilder->getQuerySegmentList(); - $querySegmentListProcessor = $this->queryEngineFactory->newQuerySegmentListProcessor(); + // $querySegmentListProcessor = $this->queryEngineFactory->newQuerySegmentListProcessor(); + $querySegmentListProcessor = $this->newQuerySegmentListProcessor(); $querySegmentListProcessor->setQuerySegmentList( $querySegmentList ); @@ -126,30 +179,46 @@ private function getPanesOptions( $qobj = $querySegmentList[$rootid]; + $tables = $querySegmentListProcessor->fromTables; + $joins = $querySegmentListProcessor->joinConditions; + + $tables[$qobj->alias] = $qobj->joinTable; + + $conds = $qobj->where; + $property = new DIProperty( DIProperty::newFromUserLabel( $printRequest->getCanonicalLabel() ) ); $propTypeid = $property->findPropertyValueType(); if ( $isCategory ) { - // data-length without the GROUP BY clause - $sql_options = [ 'LIMIT' => 1 ]; + $sql_options_ = [ 'LIMIT' => 1 ]; + + $tables_ = $tables; + $tables_['insts'] = 'smw_fpt_inst'; + $fields_ = [ 'count' => 'COUNT(*)' ]; + $conds_ = $conds; + $joins_ = $joins; + $joins_['insts'] = [ 'JOIN', [ "$qobj->alias.smw_id = insts.s_id" ] ]; - $dataLength = (int)$this->connection->selectField( - $this->connection->tableName( $qobj->joinTable ) . " AS $qobj->alias" . $qobj->from - . ' JOIN ' . $this->connection->tableName( 'smw_fpt_inst' ) . " AS insts ON $qobj->alias.smw_id = insts.s_id", - "COUNT(*) AS count", - $qobj->where, + $res = $this->connection->select( + $tables_, + $fields_, + $conds_, __METHOD__, - $sql_options + $sql_options_, + $joins_ ); + $row = $res->fetchRow(); + $dataLength = (int)( $row['count'] ?? 0 ); + if ( !$dataLength ) { return []; } $groupBy = "i.smw_id"; $orderBy = "count DESC, $groupBy ASC"; - $sql_options = [ + $sql_options_ = [ 'GROUP BY' => $groupBy, // $this->query->getOption( 'count' ), 'LIMIT' => $dataLength, @@ -167,15 +236,22 @@ private function getPanesOptions( HAVING COUNT(i.smw_id) >= 1 ORDER BY COUNT(i.smw_id) DESC */ + $tables_ = $tables; + $tables_['insts'] = 'smw_fpt_inst'; + $tables_['i'] = SQLStore::ID_TABLE; + $joins_ = $joins; + $joins_['insts'] = [ 'JOIN', [ "$qobj->alias.smw_id = insts.s_id" ] ]; + $joins_['i'] = [ 'JOIN', [ 'i.smw_id = insts.o_id' ] ]; + $conds_ = $conds; + $fields_ = "COUNT($groupBy) AS count, i.smw_id, i.smw_title, i.smw_namespace, i.smw_iw, i.smw_sort, i.smw_subobject"; + $res = $this->connection->select( - $this->connection->tableName( $qobj->joinTable ) . " AS $qobj->alias" . $qobj->from - // @see https://github.com/SemanticMediaWiki/SemanticDrilldown/blob/master/includes/Sql/SqlProvider.php - . ' JOIN ' . $this->connection->tableName( 'smw_fpt_inst' ) . " AS insts ON $qobj->alias.smw_id = insts.s_id" - . ' JOIN ' . $this->connection->tableName( SQLStore::ID_TABLE ) . " AS i ON i.smw_id = insts.o_id", - "COUNT($groupBy) AS count, i.smw_id, i.smw_title, i.smw_namespace, i.smw_iw, i.smw_sort, i.smw_subobject", - $qobj->where, + $tables_, + $fields_, + $conds_, __METHOD__, - $sql_options + $sql_options_, + $joins_ ); $isIdField = true; @@ -203,25 +279,34 @@ private function getPanesOptions( } // data-length without the GROUP BY clause - $sql_options = [ 'LIMIT' => 1 ]; + $sql_options_ = [ 'LIMIT' => 1 ]; // SELECT COUNT(*) as count FROM `smw_object_ids` AS t0 // INNER JOIN (`smw_fpt_mdat` AS t2 INNER JOIN `smw_di_wikipage` AS t3 ON t2.s_id=t3.s_id) ON t0.smw_id=t2.s_id // WHERE ((t3.p_id=517)) LIMIT 500 - $dataLength = (int)$this->connection->selectField( - $this->connection->tableName( $qobj->joinTable ) . " AS $qobj->alias" . $qobj->from, - "COUNT(*) as count", - $qobj->where, + $tables_ = $tables; + $fields_ = [ 'count' => 'COUNT(*)' ]; + $conds_ = $conds; + $joins_ = $joins; + + $res = $this->connection->select( + $tables_, + $fields_, + $conds_, __METHOD__, - $sql_options + $sql_options_, + $joins_ ); + $row = $res->fetchRow(); + $dataLength = (int)( $row['count'] ?? 0 ); + if ( !$dataLength ) { return []; } - [ $diType, $isIdField, $fields, $groupBy, $orderBy ] = $this->fetchValuesByGroup( $property, $p_alias, $propTypeid ); + [ $diType, $isIdField, $fields_, $groupBy, $orderBy ] = $this->fetchValuesByGroup( $property, $p_alias, $propTypeid ); /* ---GENERATED FROM DATATABLES @@ -231,7 +316,7 @@ private function getPanesOptions( SELECT i.smw_id,i.smw_title,i.smw_namespace,i.smw_iw,i.smw_subobject,i.smw_hash,i.smw_sort,COUNT( p.o_id ) as count FROM `smw_object_ids` `o` INNER JOIN `smw_di_wikipage` `p` ON ((p.s_id=o.smw_id)) JOIN `smw_object_ids` `i` ON ((p.o_id=i.smw_id)) WHERE o.smw_hash IN ('1_-_A','1_-_Ab','1_-_Abc','10_-_Abcd','11_-_Abc') AND (o.smw_iw!=':smw') AND (o.smw_iw!=':smw-delete') AND p.p_id = 517 GROUP BY p.o_id, i.smw_id ORDER BY count DESC, i.smw_sort ASC */ - $sql_options = [ + $sql_options_ = [ 'GROUP BY' => $groupBy, // the following implies that if the user sets a threshold // close or equal to 1, and there are too many unique values, @@ -243,18 +328,26 @@ private function getPanesOptions( ]; // @see QueryEngine + $tables_ = $tables; + $joins_ = $joins; + $conds_ = $conds; + + if ( $isIdField ) { + $tables_['i'] = SQLStore::ID_TABLE; + $joins_['i'] = [ 'JOIN', "$p_alias.o_id = i.smw_id" ]; + $conds_ .= !empty( $conds_ ) ? ' AND' : ''; + $conds_ .= ' i.smw_iw != ' . $this->connection->addQuotes( SMW_SQL3_SMWIW_OUTDATED ); + $conds_ .= ' AND i.smw_iw != ' . $this->connection->addQuotes( SMW_SQL3_SMWDELETEIW ); + } + $res = $this->connection->select( - $this->connection->tableName( $qobj->joinTable ) . " AS $qobj->alias" . $qobj->from - . ( !$isIdField ? '' - : " JOIN " . $this->connection->tableName( SQLStore::ID_TABLE ) . " AS `i` ON ($p_alias.o_id = i.smw_id)" ), - implode( ',', $fields ), - $qobj->where . ( !$isIdField ? '' : ( !empty( $qobj->where ) ? ' AND' : '' ) - . ' i.smw_iw!=' . $this->connection->addQuotes( SMW_SQL3_SMWIW_OUTDATED ) - . ' AND i.smw_iw!=' . $this->connection->addQuotes( SMW_SQL3_SMWDELETEIW ) ), + $tables_, + $fields_, + $conds_, __METHOD__, - $sql_options + $sql_options_, + $joins_ ); - } // verify uniqueRatio @@ -307,7 +400,6 @@ private function getPanesOptions( $isSubject = false; $groups = []; foreach ( $res as $row ) { - if ( $isIdField ) { $dbKeys = [ $row->smw_title, @@ -352,9 +444,7 @@ private function getPanesOptions( $dataValue->setOutputFormat( $outputFormat ); } -/* - - + /* // @see DIBlobHandler // $isKeyword = $dataItem->getOption( 'is.keyword' ); @@ -484,7 +574,6 @@ private function getPanesOptions( if ( $uniqueRatio > $threshold ) { return []; } - } arsort( $groups, SORT_NUMERIC ); @@ -619,7 +708,8 @@ private function searchPanesMainlabel( PrintRequest $printRequest, array $search QuerySegment::$qnum = 0; $querySegmentList = $conditionBuilder->getQuerySegmentList(); - $querySegmentListProcessor = $this->queryEngineFactory->newQuerySegmentListProcessor(); + // $querySegmentListProcessor = $this->queryEngineFactory->newQuerySegmentListProcessor(); + $querySegmentListProcessor = $this->newQuerySegmentListProcessor(); $querySegmentListProcessor->setQuerySegmentList( $querySegmentList ); @@ -628,7 +718,14 @@ private function searchPanesMainlabel( PrintRequest $printRequest, array $search $qobj = $querySegmentList[$rootid]; - $sql_options = [ + $tables = $querySegmentListProcessor->fromTables; + $joins = $querySegmentListProcessor->joinConditions; + + $tables[$qobj->alias] = $qobj->joinTable; + + $conds = $qobj->where; + + $sql_options_ = [ // *** should we set a limit here ? // it makes sense to show the pane for // mainlabel only when page titles are grouped @@ -639,21 +736,29 @@ private function searchPanesMainlabel( PrintRequest $printRequest, array $search // Selecting those is required in standard SQL (but MySQL does not require it). $sortfields = implode( ',', $qobj->sortfields ); - $sortfields = $sortfields ? ',' . $sortfields : ''; + // $sortfields = $sortfields ? ',' . $sortfields : ''; // @see QueryEngine + $tables_ = $tables; + $fields_ = []; + $fields_['id'] = "$qobj->alias.smw_id"; + $fields_['t'] = "$qobj->alias.smw_title"; + $fields_['ns'] = "$qobj->alias.smw_namespace"; + $fields_['iw'] = "$qobj->alias.smw_iw"; + $fields_['so'] = "$qobj->alias.smw_subobject"; + $fields_['sortkey'] = "$qobj->alias.smw_sortkey"; + $fields_[] = $sortfields; + + $conds_ = $conds; + $joins_ = $joins; + $res = $this->connection->select( - $this->connection->tableName( $qobj->joinTable ) . " AS $qobj->alias" . $qobj->from, - "$qobj->alias.smw_id AS id," . - "$qobj->alias.smw_title AS t," . - "$qobj->alias.smw_namespace AS ns," . - "$qobj->alias.smw_iw AS iw," . - "$qobj->alias.smw_subobject AS so," . - "$qobj->alias.smw_sortkey AS sortkey" . - "$sortfields", - $qobj->where, + $tables_, + $fields_, + $conds_, __METHOD__, - $sql_options + $sql_options_, + $joins_ ); $diHandler = $this->datatables->store->getDataItemHandlerForDIType( diff --git a/formats/filtered/package.json b/formats/filtered/package.json index 052ac757a..c44e80c3e 100644 --- a/formats/filtered/package.json +++ b/formats/filtered/package.json @@ -1,6 +1,6 @@ { "name": "filtered", - "version": "2.0.0", + "version": "2.1.0", "description": "Displays SMW query results in switchable views and offers client-side (JavaScript based) filtering", "main": "ext.srf.filtered.js", "scripts": { @@ -9,15 +9,17 @@ }, "author": "Stephan Gambke", "license": "GPL-2.0-or-later", - "dependencies": {}, + "dependencies": { + }, "devDependencies": { - "@types/fullcalendar": "2.7.42", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", "@types/ion.rangeslider": "^2.0.29", "@types/jquery": "^2.0.51", "@types/jqueryui": "^1.12.5", - "@types/leaflet": "~1.0.60", - "@types/leaflet-providers": "^1.1.0", - "@types/leaflet.markercluster": "1.0.0", + "@types/leaflet": "^1.9.16", + "@types/leaflet-providers": "^1.2.4", + "@types/leaflet.markercluster": "^1.5.5", "@types/qunit": "^1.16.31", "@types/select2": "^4.0.47", "browserify": "^16.2.3", @@ -28,14 +30,14 @@ "gulp-sourcemaps": "^2.4.1", "gulp-uglify": "^2.1.2", "ion-rangeslider": "^2.2.0", - "leaflet": "^1.3.4", - "leaflet-providers": "^1.4.0", - "leaflet.markercluster": "~1.2", + "leaflet": "^1.9.4", + "leaflet-providers": "^2.0.0", + "leaflet.markercluster": "^1.5.3", "nouislider": "^10.1.0", "select2": "4.0.3", "tsify": "^3.0.1", - "typescript": "^3.1.3", "vinyl-buffer": "^1.0.0", - "vinyl-source-stream": "^2.0.0" + "vinyl-source-stream": "^2.0.0", + "typescript": "^5.7.3" } } diff --git a/formats/filtered/resources/css/ext.srf.filtered.leaflet.css b/formats/filtered/resources/css/ext.srf.filtered.leaflet.css index 99786d4c4..fd50fb27e 100644 --- a/formats/filtered/resources/css/ext.srf.filtered.leaflet.css +++ b/formats/filtered/resources/css/ext.srf.filtered.leaflet.css @@ -25,6 +25,10 @@ user-select: none; -webkit-user-drag: none; } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} /* Safari renders non-retina tile on retina better with this, but Chrome is worse */ .leaflet-safari .leaflet-tile { image-rendering: -webkit-optimize-contrast; @@ -41,7 +45,10 @@ } /* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ /* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ -.leaflet-container .leaflet-overlay-pane svg, +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } .leaflet-container .leaflet-marker-pane img, .leaflet-container .leaflet-shadow-pane img, .leaflet-container .leaflet-tile-pane img, @@ -49,8 +56,15 @@ .leaflet-container .leaflet-tile { max-width: none !important; max-height: none !important; + width: auto; + padding: 0; } +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + .leaflet-container.leaflet-touch-zoom { -ms-touch-action: pan-x pan-y; touch-action: pan-x pan-y; @@ -162,9 +176,6 @@ /* zoom and fade animations */ -.leaflet-fade-anim .leaflet-tile { - will-change: opacity; - } .leaflet-fade-anim .leaflet-popup { opacity: 0; -webkit-transition: opacity 0.2s linear; @@ -179,9 +190,10 @@ -ms-transform-origin: 0 0; transform-origin: 0 0; } -.leaflet-zoom-anim .leaflet-zoom-animated { +svg.leaflet-zoom-animated { will-change: transform; - } +} + .leaflet-zoom-anim .leaflet-zoom-animated { -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); @@ -237,7 +249,8 @@ .leaflet-marker-icon.leaflet-interactive, .leaflet-image-layer.leaflet-interactive, -.leaflet-pane > svg path.leaflet-interactive { +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ pointer-events: auto; } @@ -246,14 +259,11 @@ .leaflet-container { background: #ddd; - outline: 0; + outline-offset: 1px; } .leaflet-container a { color: #0078A8; } -.leaflet-container a.leaflet-active { - outline: 2px solid orange; - } .leaflet-zoom-box { border: 2px dotted #38f; background: rgba(255,255,255,0.5); @@ -262,7 +272,10 @@ /* general typography */ .leaflet-container { - font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; } @@ -272,8 +285,7 @@ box-shadow: 0 1px 5px rgba(0,0,0,0.65); border-radius: 4px; } -.leaflet-bar a, -.leaflet-bar a:hover { +.leaflet-bar a { background-color: #fff; border-bottom: 1px solid #ccc; width: 26px; @@ -290,7 +302,8 @@ background-repeat: no-repeat; display: block; } -.leaflet-bar a:hover { +.leaflet-bar a:hover, +.leaflet-bar a:focus { background-color: #f4f4f4; } .leaflet-bar a:first-child { @@ -380,6 +393,8 @@ } .leaflet-control-layers label { display: block; + font-size: 13px; + font-size: 1.08333em; } .leaflet-control-layers-separator { height: 0; @@ -388,7 +403,7 @@ } /* Default icon URLs */ -.leaflet-default-icon-path { +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ background-image: url(images/marker-icon.png); } @@ -397,23 +412,27 @@ .leaflet-container .leaflet-control-attribution { background: #fff; - background: rgba(255, 255, 255, 0.7); + background: rgba(255, 255, 255, 0.8); margin: 0; } .leaflet-control-attribution, .leaflet-control-scale-line { padding: 0 5px; color: #333; + line-height: 1.4; } .leaflet-control-attribution a { text-decoration: none; } -.leaflet-control-attribution a:hover { +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { text-decoration: underline; } -.leaflet-container .leaflet-control-attribution, -.leaflet-container .leaflet-control-scale { - font-size: 11px; +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; } .leaflet-left .leaflet-control-scale { margin-left: 5px; @@ -426,14 +445,11 @@ border-top: none; line-height: 1.1; padding: 2px 5px 1px; - font-size: 11px; white-space: nowrap; - overflow: hidden; -moz-box-sizing: border-box; box-sizing: border-box; - - background: #fff; - background: rgba(255, 255, 255, 0.5); + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; } .leaflet-control-scale-line:not(:first-child) { border-top: 2px solid #777; @@ -469,17 +485,22 @@ border-radius: 12px; } .leaflet-popup-content { - margin: 13px 19px; - line-height: 1.4; + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; } .leaflet-popup-content p { - margin: 18px 0; + margin: 17px 0; + margin: 1.3em 0; } .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; + margin-top: -1px; margin-left: -20px; overflow: hidden; pointer-events: none; @@ -490,6 +511,7 @@ padding: 1px; margin: -10px auto 0; + pointer-events: auto; -webkit-transform: rotate(45deg); -moz-transform: rotate(45deg); @@ -506,28 +528,25 @@ position: absolute; top: 0; right: 0; - padding: 4px 4px 0 0; border: none; text-align: center; - width: 18px; - height: 14px; - font: 16px/14px Tahoma, Verdana, sans-serif; - color: #c3c3c3; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; text-decoration: none; - font-weight: bold; background: transparent; } -.leaflet-container a.leaflet-popup-close-button:hover { - color: #999; +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; } .leaflet-popup-scrolled { overflow: auto; - border-bottom: 1px solid #ddd; - border-top: 1px solid #ddd; } .leaflet-oldie .leaflet-popup-content-wrapper { - zoom: 1; + -ms-zoom: 1; } .leaflet-oldie .leaflet-popup-tip { width: 24px; @@ -536,9 +555,6 @@ -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); } -.leaflet-oldie .leaflet-popup-tip-container { - margin-top: -1px; - } .leaflet-oldie .leaflet-control-zoom, .leaflet-oldie .leaflet-control-layers, @@ -573,7 +589,7 @@ pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,0.4); } -.leaflet-tooltip.leaflet-clickable { +.leaflet-tooltip.leaflet-interactive { cursor: pointer; pointer-events: auto; } @@ -633,79 +649,89 @@ margin-left: -12px; border-right-color: #fff; } - -.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { - -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in; - -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in; - -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in; - transition: transform 0.3s ease-out, opacity 0.3s ease-in; -} - -.leaflet-cluster-spider-leg { - /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */ - -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in; - -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in; - -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in; - transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in; -} - -.marker-cluster-small { - background-color: rgba(181, 226, 140, 0.6); - } -.marker-cluster-small div { - background-color: rgba(110, 204, 57, 0.6); - } -.marker-cluster-medium { - background-color: rgba(241, 211, 87, 0.6); - } -.marker-cluster-medium div { - background-color: rgba(240, 194, 12, 0.6); - } - -.marker-cluster-large { - background-color: rgba(253, 156, 115, 0.6); - } -.marker-cluster-large div { - background-color: rgba(241, 128, 23, 0.6); - } - - /* IE 6-8 fallback colors */ -.leaflet-oldie .marker-cluster-small { - background-color: rgb(181, 226, 140); - } -.leaflet-oldie .marker-cluster-small div { - background-color: rgb(110, 204, 57); - } - -.leaflet-oldie .marker-cluster-medium { - background-color: rgb(241, 211, 87); - } -.leaflet-oldie .marker-cluster-medium div { - background-color: rgb(240, 194, 12); - } - -.leaflet-oldie .marker-cluster-large { - background-color: rgb(253, 156, 115); - } -.leaflet-oldie .marker-cluster-large div { - background-color: rgb(241, 128, 23); -} - -.marker-cluster { - background-clip: padding-box; - border-radius: 20px; - } -.marker-cluster div { - width: 30px; - height: 30px; - margin-left: 5px; - margin-top: 5px; +/* Printing */ - text-align: center; - border-radius: 15px; - font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif; +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } } -.marker-cluster span { - line-height: 30px; + +.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { + -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in; + -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in; + -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in; + transition: transform 0.3s ease-out, opacity 0.3s ease-in; +} + +.leaflet-cluster-spider-leg { + /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */ + -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in; + -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in; + -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in; + transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in; +} + +.marker-cluster-small { + background-color: rgba(181, 226, 140, 0.6); + } +.marker-cluster-small div { + background-color: rgba(110, 204, 57, 0.6); + } + +.marker-cluster-medium { + background-color: rgba(241, 211, 87, 0.6); + } +.marker-cluster-medium div { + background-color: rgba(240, 194, 12, 0.6); + } + +.marker-cluster-large { + background-color: rgba(253, 156, 115, 0.6); + } +.marker-cluster-large div { + background-color: rgba(241, 128, 23, 0.6); + } + + /* IE 6-8 fallback colors */ +.leaflet-oldie .marker-cluster-small { + background-color: rgb(181, 226, 140); + } +.leaflet-oldie .marker-cluster-small div { + background-color: rgb(110, 204, 57); + } + +.leaflet-oldie .marker-cluster-medium { + background-color: rgb(241, 211, 87); + } +.leaflet-oldie .marker-cluster-medium div { + background-color: rgb(240, 194, 12); + } + +.leaflet-oldie .marker-cluster-large { + background-color: rgb(253, 156, 115); + } +.leaflet-oldie .marker-cluster-large div { + background-color: rgb(241, 128, 23); +} + +.marker-cluster { + background-clip: padding-box; + border-radius: 20px; + } +.marker-cluster div { + width: 30px; + height: 30px; + margin-left: 5px; + margin-top: 5px; + + text-align: center; + border-radius: 15px; + font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif; + } +.marker-cluster span { + line-height: 30px; } \ No newline at end of file diff --git a/formats/filtered/resources/js/ext.srf.filtered.js b/formats/filtered/resources/js/ext.srf.filtered.js index 39fea7a01..bcadd45b6 100644 --- a/formats/filtered/resources/js/ext.srf.filtered.js +++ b/formats/filtered/resources/js/ext.srf.filtered.js @@ -1,10 +1,29578 @@ (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i 0 ? explicitRawLocales[0].code : 'en'; + let allRawLocales = globalLocales.concat(explicitRawLocales); + let rawLocaleMap = { + en: RAW_EN_LOCALE, + }; + for (let rawLocale of allRawLocales) { + rawLocaleMap[rawLocale.code] = rawLocale; + } + return { + map: rawLocaleMap, + defaultCode, + }; +} +function buildLocale(inputSingular, available) { + if (typeof inputSingular === 'object' && !Array.isArray(inputSingular)) { + return parseLocale(inputSingular.code, [inputSingular.code], inputSingular); + } + return queryLocale(inputSingular, available); +} +function queryLocale(codeArg, available) { + let codes = [].concat(codeArg || []); // will convert to array + let raw = queryRawLocale(codes, available) || RAW_EN_LOCALE; + return parseLocale(codeArg, codes, raw); +} +function queryRawLocale(codes, available) { + for (let i = 0; i < codes.length; i += 1) { + let parts = codes[i].toLocaleLowerCase().split('-'); + for (let j = parts.length; j > 0; j -= 1) { + let simpleId = parts.slice(0, j).join('-'); + if (available[simpleId]) { + return available[simpleId]; + } + } + } + return null; +} +function parseLocale(codeArg, codes, raw) { + let merged = internalCommon.mergeProps([MINIMAL_RAW_EN_LOCALE, raw], ['buttonText']); + delete merged.code; // don't want this part of the options + let { week } = merged; + delete merged.week; + return { + codeArg, + codes, + week, + simpleNumberFormat: new Intl.NumberFormat(codeArg), + options: merged, + }; +} + +// TODO: easier way to add new hooks? need to update a million things +function createPlugin(input) { + return { + id: internalCommon.guid(), + name: input.name, + premiumReleaseDate: input.premiumReleaseDate ? new Date(input.premiumReleaseDate) : undefined, + deps: input.deps || [], + reducers: input.reducers || [], + isLoadingFuncs: input.isLoadingFuncs || [], + contextInit: [].concat(input.contextInit || []), + eventRefiners: input.eventRefiners || {}, + eventDefMemberAdders: input.eventDefMemberAdders || [], + eventSourceRefiners: input.eventSourceRefiners || {}, + isDraggableTransformers: input.isDraggableTransformers || [], + eventDragMutationMassagers: input.eventDragMutationMassagers || [], + eventDefMutationAppliers: input.eventDefMutationAppliers || [], + dateSelectionTransformers: input.dateSelectionTransformers || [], + datePointTransforms: input.datePointTransforms || [], + dateSpanTransforms: input.dateSpanTransforms || [], + views: input.views || {}, + viewPropsTransformers: input.viewPropsTransformers || [], + isPropsValid: input.isPropsValid || null, + externalDefTransforms: input.externalDefTransforms || [], + viewContainerAppends: input.viewContainerAppends || [], + eventDropTransformers: input.eventDropTransformers || [], + componentInteractions: input.componentInteractions || [], + calendarInteractions: input.calendarInteractions || [], + themeClasses: input.themeClasses || {}, + eventSourceDefs: input.eventSourceDefs || [], + cmdFormatter: input.cmdFormatter, + recurringTypes: input.recurringTypes || [], + namedTimeZonedImpl: input.namedTimeZonedImpl, + initialView: input.initialView || '', + elementDraggingImpl: input.elementDraggingImpl, + optionChangeHandlers: input.optionChangeHandlers || {}, + scrollGridImpl: input.scrollGridImpl || null, + listenerRefiners: input.listenerRefiners || {}, + optionRefiners: input.optionRefiners || {}, + propSetHandlers: input.propSetHandlers || {}, + }; +} +function buildPluginHooks(pluginDefs, globalDefs) { + let currentPluginIds = {}; + let hooks = { + premiumReleaseDate: undefined, + reducers: [], + isLoadingFuncs: [], + contextInit: [], + eventRefiners: {}, + eventDefMemberAdders: [], + eventSourceRefiners: {}, + isDraggableTransformers: [], + eventDragMutationMassagers: [], + eventDefMutationAppliers: [], + dateSelectionTransformers: [], + datePointTransforms: [], + dateSpanTransforms: [], + views: {}, + viewPropsTransformers: [], + isPropsValid: null, + externalDefTransforms: [], + viewContainerAppends: [], + eventDropTransformers: [], + componentInteractions: [], + calendarInteractions: [], + themeClasses: {}, + eventSourceDefs: [], + cmdFormatter: null, + recurringTypes: [], + namedTimeZonedImpl: null, + initialView: '', + elementDraggingImpl: null, + optionChangeHandlers: {}, + scrollGridImpl: null, + listenerRefiners: {}, + optionRefiners: {}, + propSetHandlers: {}, + }; + function addDefs(defs) { + for (let def of defs) { + const pluginName = def.name; + const currentId = currentPluginIds[pluginName]; + if (currentId === undefined) { + currentPluginIds[pluginName] = def.id; + addDefs(def.deps); + hooks = combineHooks(hooks, def); + } + else if (currentId !== def.id) { + // different ID than the one already added + console.warn(`Duplicate plugin '${pluginName}'`); + } + } + } + if (pluginDefs) { + addDefs(pluginDefs); + } + addDefs(globalDefs); + return hooks; +} +function buildBuildPluginHooks() { + let currentOverrideDefs = []; + let currentGlobalDefs = []; + let currentHooks; + return (overrideDefs, globalDefs) => { + if (!currentHooks || !internalCommon.isArraysEqual(overrideDefs, currentOverrideDefs) || !internalCommon.isArraysEqual(globalDefs, currentGlobalDefs)) { + currentHooks = buildPluginHooks(overrideDefs, globalDefs); + } + currentOverrideDefs = overrideDefs; + currentGlobalDefs = globalDefs; + return currentHooks; + }; +} +function combineHooks(hooks0, hooks1) { + return { + premiumReleaseDate: compareOptionalDates(hooks0.premiumReleaseDate, hooks1.premiumReleaseDate), + reducers: hooks0.reducers.concat(hooks1.reducers), + isLoadingFuncs: hooks0.isLoadingFuncs.concat(hooks1.isLoadingFuncs), + contextInit: hooks0.contextInit.concat(hooks1.contextInit), + eventRefiners: Object.assign(Object.assign({}, hooks0.eventRefiners), hooks1.eventRefiners), + eventDefMemberAdders: hooks0.eventDefMemberAdders.concat(hooks1.eventDefMemberAdders), + eventSourceRefiners: Object.assign(Object.assign({}, hooks0.eventSourceRefiners), hooks1.eventSourceRefiners), + isDraggableTransformers: hooks0.isDraggableTransformers.concat(hooks1.isDraggableTransformers), + eventDragMutationMassagers: hooks0.eventDragMutationMassagers.concat(hooks1.eventDragMutationMassagers), + eventDefMutationAppliers: hooks0.eventDefMutationAppliers.concat(hooks1.eventDefMutationAppliers), + dateSelectionTransformers: hooks0.dateSelectionTransformers.concat(hooks1.dateSelectionTransformers), + datePointTransforms: hooks0.datePointTransforms.concat(hooks1.datePointTransforms), + dateSpanTransforms: hooks0.dateSpanTransforms.concat(hooks1.dateSpanTransforms), + views: Object.assign(Object.assign({}, hooks0.views), hooks1.views), + viewPropsTransformers: hooks0.viewPropsTransformers.concat(hooks1.viewPropsTransformers), + isPropsValid: hooks1.isPropsValid || hooks0.isPropsValid, + externalDefTransforms: hooks0.externalDefTransforms.concat(hooks1.externalDefTransforms), + viewContainerAppends: hooks0.viewContainerAppends.concat(hooks1.viewContainerAppends), + eventDropTransformers: hooks0.eventDropTransformers.concat(hooks1.eventDropTransformers), + calendarInteractions: hooks0.calendarInteractions.concat(hooks1.calendarInteractions), + componentInteractions: hooks0.componentInteractions.concat(hooks1.componentInteractions), + themeClasses: Object.assign(Object.assign({}, hooks0.themeClasses), hooks1.themeClasses), + eventSourceDefs: hooks0.eventSourceDefs.concat(hooks1.eventSourceDefs), + cmdFormatter: hooks1.cmdFormatter || hooks0.cmdFormatter, + recurringTypes: hooks0.recurringTypes.concat(hooks1.recurringTypes), + namedTimeZonedImpl: hooks1.namedTimeZonedImpl || hooks0.namedTimeZonedImpl, + initialView: hooks0.initialView || hooks1.initialView, + elementDraggingImpl: hooks0.elementDraggingImpl || hooks1.elementDraggingImpl, + optionChangeHandlers: Object.assign(Object.assign({}, hooks0.optionChangeHandlers), hooks1.optionChangeHandlers), + scrollGridImpl: hooks1.scrollGridImpl || hooks0.scrollGridImpl, + listenerRefiners: Object.assign(Object.assign({}, hooks0.listenerRefiners), hooks1.listenerRefiners), + optionRefiners: Object.assign(Object.assign({}, hooks0.optionRefiners), hooks1.optionRefiners), + propSetHandlers: Object.assign(Object.assign({}, hooks0.propSetHandlers), hooks1.propSetHandlers), + }; +} +function compareOptionalDates(date0, date1) { + if (date0 === undefined) { + return date1; + } + if (date1 === undefined) { + return date0; + } + return new Date(Math.max(date0.valueOf(), date1.valueOf())); +} + +class StandardTheme extends internalCommon.Theme { +} +StandardTheme.prototype.classes = { + root: 'fc-theme-standard', + tableCellShaded: 'fc-cell-shaded', + buttonGroup: 'fc-button-group', + button: 'fc-button fc-button-primary', + buttonActive: 'fc-button-active', +}; +StandardTheme.prototype.baseIconClass = 'fc-icon'; +StandardTheme.prototype.iconClasses = { + close: 'fc-icon-x', + prev: 'fc-icon-chevron-left', + next: 'fc-icon-chevron-right', + prevYear: 'fc-icon-chevrons-left', + nextYear: 'fc-icon-chevrons-right', +}; +StandardTheme.prototype.rtlIconClasses = { + prev: 'fc-icon-chevron-right', + next: 'fc-icon-chevron-left', + prevYear: 'fc-icon-chevrons-right', + nextYear: 'fc-icon-chevrons-left', +}; +StandardTheme.prototype.iconOverrideOption = 'buttonIcons'; // TODO: make TS-friendly +StandardTheme.prototype.iconOverrideCustomButtonOption = 'icon'; +StandardTheme.prototype.iconOverridePrefix = 'fc-icon-'; + +function compileViewDefs(defaultConfigs, overrideConfigs) { + let hash = {}; + let viewType; + for (viewType in defaultConfigs) { + ensureViewDef(viewType, hash, defaultConfigs, overrideConfigs); + } + for (viewType in overrideConfigs) { + ensureViewDef(viewType, hash, defaultConfigs, overrideConfigs); + } + return hash; +} +function ensureViewDef(viewType, hash, defaultConfigs, overrideConfigs) { + if (hash[viewType]) { + return hash[viewType]; + } + let viewDef = buildViewDef(viewType, hash, defaultConfigs, overrideConfigs); + if (viewDef) { + hash[viewType] = viewDef; + } + return viewDef; +} +function buildViewDef(viewType, hash, defaultConfigs, overrideConfigs) { + let defaultConfig = defaultConfigs[viewType]; + let overrideConfig = overrideConfigs[viewType]; + let queryProp = (name) => ((defaultConfig && defaultConfig[name] !== null) ? defaultConfig[name] : + ((overrideConfig && overrideConfig[name] !== null) ? overrideConfig[name] : null)); + let theComponent = queryProp('component'); + let superType = queryProp('superType'); + let superDef = null; + if (superType) { + if (superType === viewType) { + throw new Error('Can\'t have a custom view type that references itself'); + } + superDef = ensureViewDef(superType, hash, defaultConfigs, overrideConfigs); + } + if (!theComponent && superDef) { + theComponent = superDef.component; + } + if (!theComponent) { + return null; // don't throw a warning, might be settings for a single-unit view + } + return { + type: viewType, + component: theComponent, + defaults: Object.assign(Object.assign({}, (superDef ? superDef.defaults : {})), (defaultConfig ? defaultConfig.rawOptions : {})), + overrides: Object.assign(Object.assign({}, (superDef ? superDef.overrides : {})), (overrideConfig ? overrideConfig.rawOptions : {})), + }; +} + +function parseViewConfigs(inputs) { + return internalCommon.mapHash(inputs, parseViewConfig); +} +function parseViewConfig(input) { + let rawOptions = typeof input === 'function' ? + { component: input } : + input; + let { component } = rawOptions; + if (rawOptions.content) { + // TODO: remove content/classNames/didMount/etc from options? + component = createViewHookComponent(rawOptions); + } + else if (component && !(component.prototype instanceof internalCommon.BaseComponent)) { + // WHY?: people were using `component` property for `content` + // TODO: converge on one setting name + component = createViewHookComponent(Object.assign(Object.assign({}, rawOptions), { content: component })); + } + return { + superType: rawOptions.type, + component: component, + rawOptions, // includes type and component too :( + }; +} +function createViewHookComponent(options) { + return (viewProps) => (preact.createElement(internalCommon.ViewContextType.Consumer, null, (context) => (preact.createElement(internalCommon.ContentContainer, { elTag: "div", elClasses: internalCommon.buildViewClassNames(context.viewSpec), renderProps: Object.assign(Object.assign({}, viewProps), { nextDayThreshold: context.options.nextDayThreshold }), generatorName: undefined, customGenerator: options.content, classNameGenerator: options.classNames, didMount: options.didMount, willUnmount: options.willUnmount })))); +} + +function buildViewSpecs(defaultInputs, optionOverrides, dynamicOptionOverrides, localeDefaults) { + let defaultConfigs = parseViewConfigs(defaultInputs); + let overrideConfigs = parseViewConfigs(optionOverrides.views); + let viewDefs = compileViewDefs(defaultConfigs, overrideConfigs); + return internalCommon.mapHash(viewDefs, (viewDef) => buildViewSpec(viewDef, overrideConfigs, optionOverrides, dynamicOptionOverrides, localeDefaults)); +} +function buildViewSpec(viewDef, overrideConfigs, optionOverrides, dynamicOptionOverrides, localeDefaults) { + let durationInput = viewDef.overrides.duration || + viewDef.defaults.duration || + dynamicOptionOverrides.duration || + optionOverrides.duration; + let duration = null; + let durationUnit = ''; + let singleUnit = ''; + let singleUnitOverrides = {}; + if (durationInput) { + duration = createDurationCached(durationInput); + if (duration) { // valid? + let denom = internalCommon.greatestDurationDenominator(duration); + durationUnit = denom.unit; + if (denom.value === 1) { + singleUnit = durationUnit; + singleUnitOverrides = overrideConfigs[durationUnit] ? overrideConfigs[durationUnit].rawOptions : {}; + } + } + } + let queryButtonText = (optionsSubset) => { + let buttonTextMap = optionsSubset.buttonText || {}; + let buttonTextKey = viewDef.defaults.buttonTextKey; + if (buttonTextKey != null && buttonTextMap[buttonTextKey] != null) { + return buttonTextMap[buttonTextKey]; + } + if (buttonTextMap[viewDef.type] != null) { + return buttonTextMap[viewDef.type]; + } + if (buttonTextMap[singleUnit] != null) { + return buttonTextMap[singleUnit]; + } + return null; + }; + let queryButtonTitle = (optionsSubset) => { + let buttonHints = optionsSubset.buttonHints || {}; + let buttonKey = viewDef.defaults.buttonTextKey; // use same key as text + if (buttonKey != null && buttonHints[buttonKey] != null) { + return buttonHints[buttonKey]; + } + if (buttonHints[viewDef.type] != null) { + return buttonHints[viewDef.type]; + } + if (buttonHints[singleUnit] != null) { + return buttonHints[singleUnit]; + } + return null; + }; + return { + type: viewDef.type, + component: viewDef.component, + duration, + durationUnit, + singleUnit, + optionDefaults: viewDef.defaults, + optionOverrides: Object.assign(Object.assign({}, singleUnitOverrides), viewDef.overrides), + buttonTextOverride: queryButtonText(dynamicOptionOverrides) || + queryButtonText(optionOverrides) || // constructor-specified buttonText lookup hash takes precedence + viewDef.overrides.buttonText, + buttonTextDefault: queryButtonText(localeDefaults) || + viewDef.defaults.buttonText || + queryButtonText(internalCommon.BASE_OPTION_DEFAULTS) || + viewDef.type, + // not DRY + buttonTitleOverride: queryButtonTitle(dynamicOptionOverrides) || + queryButtonTitle(optionOverrides) || + viewDef.overrides.buttonHint, + buttonTitleDefault: queryButtonTitle(localeDefaults) || + viewDef.defaults.buttonHint || + queryButtonTitle(internalCommon.BASE_OPTION_DEFAULTS), + // will eventually fall back to buttonText + }; +} +// hack to get memoization working +let durationInputMap = {}; +function createDurationCached(durationInput) { + let json = JSON.stringify(durationInput); + let res = durationInputMap[json]; + if (res === undefined) { + res = internalCommon.createDuration(durationInput); + durationInputMap[json] = res; + } + return res; +} + +function reduceViewType(viewType, action) { + switch (action.type) { + case 'CHANGE_VIEW_TYPE': + viewType = action.viewType; + } + return viewType; +} + +function reduceDynamicOptionOverrides(dynamicOptionOverrides, action) { + switch (action.type) { + case 'SET_OPTION': + return Object.assign(Object.assign({}, dynamicOptionOverrides), { [action.optionName]: action.rawOptionValue }); + default: + return dynamicOptionOverrides; + } +} + +function reduceDateProfile(currentDateProfile, action, currentDate, dateProfileGenerator) { + let dp; + switch (action.type) { + case 'CHANGE_VIEW_TYPE': + return dateProfileGenerator.build(action.dateMarker || currentDate); + case 'CHANGE_DATE': + return dateProfileGenerator.build(action.dateMarker); + case 'PREV': + dp = dateProfileGenerator.buildPrev(currentDateProfile, currentDate); + if (dp.isValid) { + return dp; + } + break; + case 'NEXT': + dp = dateProfileGenerator.buildNext(currentDateProfile, currentDate); + if (dp.isValid) { + return dp; + } + break; + } + return currentDateProfile; +} + +function initEventSources(calendarOptions, dateProfile, context) { + let activeRange = dateProfile ? dateProfile.activeRange : null; + return addSources({}, parseInitialSources(calendarOptions, context), activeRange, context); +} +function reduceEventSources(eventSources, action, dateProfile, context) { + let activeRange = dateProfile ? dateProfile.activeRange : null; // need this check? + switch (action.type) { + case 'ADD_EVENT_SOURCES': // already parsed + return addSources(eventSources, action.sources, activeRange, context); + case 'REMOVE_EVENT_SOURCE': + return removeSource(eventSources, action.sourceId); + case 'PREV': // TODO: how do we track all actions that affect dateProfile :( + case 'NEXT': + case 'CHANGE_DATE': + case 'CHANGE_VIEW_TYPE': + if (dateProfile) { + return fetchDirtySources(eventSources, activeRange, context); + } + return eventSources; + case 'FETCH_EVENT_SOURCES': + return fetchSourcesByIds(eventSources, action.sourceIds ? // why no type? + internalCommon.arrayToHash(action.sourceIds) : + excludeStaticSources(eventSources, context), activeRange, action.isRefetch || false, context); + case 'RECEIVE_EVENTS': + case 'RECEIVE_EVENT_ERROR': + return receiveResponse(eventSources, action.sourceId, action.fetchId, action.fetchRange); + case 'REMOVE_ALL_EVENT_SOURCES': + return {}; + default: + return eventSources; + } +} +function reduceEventSourcesNewTimeZone(eventSources, dateProfile, context) { + let activeRange = dateProfile ? dateProfile.activeRange : null; // need this check? + return fetchSourcesByIds(eventSources, excludeStaticSources(eventSources, context), activeRange, true, context); +} +function computeEventSourcesLoading(eventSources) { + for (let sourceId in eventSources) { + if (eventSources[sourceId].isFetching) { + return true; + } + } + return false; +} +function addSources(eventSourceHash, sources, fetchRange, context) { + let hash = {}; + for (let source of sources) { + hash[source.sourceId] = source; + } + if (fetchRange) { + hash = fetchDirtySources(hash, fetchRange, context); + } + return Object.assign(Object.assign({}, eventSourceHash), hash); +} +function removeSource(eventSourceHash, sourceId) { + return internalCommon.filterHash(eventSourceHash, (eventSource) => eventSource.sourceId !== sourceId); +} +function fetchDirtySources(sourceHash, fetchRange, context) { + return fetchSourcesByIds(sourceHash, internalCommon.filterHash(sourceHash, (eventSource) => isSourceDirty(eventSource, fetchRange, context)), fetchRange, false, context); +} +function isSourceDirty(eventSource, fetchRange, context) { + if (!doesSourceNeedRange(eventSource, context)) { + return !eventSource.latestFetchId; + } + return !context.options.lazyFetching || + !eventSource.fetchRange || + eventSource.isFetching || // always cancel outdated in-progress fetches + fetchRange.start < eventSource.fetchRange.start || + fetchRange.end > eventSource.fetchRange.end; +} +function fetchSourcesByIds(prevSources, sourceIdHash, fetchRange, isRefetch, context) { + let nextSources = {}; + for (let sourceId in prevSources) { + let source = prevSources[sourceId]; + if (sourceIdHash[sourceId]) { + nextSources[sourceId] = fetchSource(source, fetchRange, isRefetch, context); + } + else { + nextSources[sourceId] = source; + } + } + return nextSources; +} +function fetchSource(eventSource, fetchRange, isRefetch, context) { + let { options, calendarApi } = context; + let sourceDef = context.pluginHooks.eventSourceDefs[eventSource.sourceDefId]; + let fetchId = internalCommon.guid(); + sourceDef.fetch({ + eventSource, + range: fetchRange, + isRefetch, + context, + }, (res) => { + let { rawEvents } = res; + if (options.eventSourceSuccess) { + rawEvents = options.eventSourceSuccess.call(calendarApi, rawEvents, res.response) || rawEvents; + } + if (eventSource.success) { + rawEvents = eventSource.success.call(calendarApi, rawEvents, res.response) || rawEvents; + } + context.dispatch({ + type: 'RECEIVE_EVENTS', + sourceId: eventSource.sourceId, + fetchId, + fetchRange, + rawEvents, + }); + }, (error) => { + let errorHandled = false; + if (options.eventSourceFailure) { + options.eventSourceFailure.call(calendarApi, error); + errorHandled = true; + } + if (eventSource.failure) { + eventSource.failure(error); + errorHandled = true; + } + if (!errorHandled) { + console.warn(error.message, error); + } + context.dispatch({ + type: 'RECEIVE_EVENT_ERROR', + sourceId: eventSource.sourceId, + fetchId, + fetchRange, + error, + }); + }); + return Object.assign(Object.assign({}, eventSource), { isFetching: true, latestFetchId: fetchId }); +} +function receiveResponse(sourceHash, sourceId, fetchId, fetchRange) { + let eventSource = sourceHash[sourceId]; + if (eventSource && // not already removed + fetchId === eventSource.latestFetchId) { + return Object.assign(Object.assign({}, sourceHash), { [sourceId]: Object.assign(Object.assign({}, eventSource), { isFetching: false, fetchRange }) }); + } + return sourceHash; +} +function excludeStaticSources(eventSources, context) { + return internalCommon.filterHash(eventSources, (eventSource) => doesSourceNeedRange(eventSource, context)); +} +function parseInitialSources(rawOptions, context) { + let refiners = internalCommon.buildEventSourceRefiners(context); + let rawSources = [].concat(rawOptions.eventSources || []); + let sources = []; // parsed + if (rawOptions.initialEvents) { + rawSources.unshift(rawOptions.initialEvents); + } + if (rawOptions.events) { + rawSources.unshift(rawOptions.events); + } + for (let rawSource of rawSources) { + let source = internalCommon.parseEventSource(rawSource, context, refiners); + if (source) { + sources.push(source); + } + } + return sources; +} +function doesSourceNeedRange(eventSource, context) { + let defs = context.pluginHooks.eventSourceDefs; + return !defs[eventSource.sourceDefId].ignoreRange; +} + +function reduceDateSelection(currentSelection, action) { + switch (action.type) { + case 'UNSELECT_DATES': + return null; + case 'SELECT_DATES': + return action.selection; + default: + return currentSelection; + } +} + +function reduceSelectedEvent(currentInstanceId, action) { + switch (action.type) { + case 'UNSELECT_EVENT': + return ''; + case 'SELECT_EVENT': + return action.eventInstanceId; + default: + return currentInstanceId; + } +} + +function reduceEventDrag(currentDrag, action) { + let newDrag; + switch (action.type) { + case 'UNSET_EVENT_DRAG': + return null; + case 'SET_EVENT_DRAG': + newDrag = action.state; + return { + affectedEvents: newDrag.affectedEvents, + mutatedEvents: newDrag.mutatedEvents, + isEvent: newDrag.isEvent, + }; + default: + return currentDrag; + } +} + +function reduceEventResize(currentResize, action) { + let newResize; + switch (action.type) { + case 'UNSET_EVENT_RESIZE': + return null; + case 'SET_EVENT_RESIZE': + newResize = action.state; + return { + affectedEvents: newResize.affectedEvents, + mutatedEvents: newResize.mutatedEvents, + isEvent: newResize.isEvent, + }; + default: + return currentResize; + } +} + +function parseToolbars(calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi) { + let header = calendarOptions.headerToolbar ? parseToolbar(calendarOptions.headerToolbar, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi) : null; + let footer = calendarOptions.footerToolbar ? parseToolbar(calendarOptions.footerToolbar, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi) : null; + return { header, footer }; +} +function parseToolbar(sectionStrHash, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi) { + let sectionWidgets = {}; + let viewsWithButtons = []; + let hasTitle = false; + for (let sectionName in sectionStrHash) { + let sectionStr = sectionStrHash[sectionName]; + let sectionRes = parseSection(sectionStr, calendarOptions, calendarOptionOverrides, theme, viewSpecs, calendarApi); + sectionWidgets[sectionName] = sectionRes.widgets; + viewsWithButtons.push(...sectionRes.viewsWithButtons); + hasTitle = hasTitle || sectionRes.hasTitle; + } + return { sectionWidgets, viewsWithButtons, hasTitle }; +} +/* +BAD: querying icons and text here. should be done at render time +*/ +function parseSection(sectionStr, calendarOptions, // defaults+overrides, then refined +calendarOptionOverrides, // overrides only!, unrefined :( +theme, viewSpecs, calendarApi) { + let isRtl = calendarOptions.direction === 'rtl'; + let calendarCustomButtons = calendarOptions.customButtons || {}; + let calendarButtonTextOverrides = calendarOptionOverrides.buttonText || {}; + let calendarButtonText = calendarOptions.buttonText || {}; + let calendarButtonHintOverrides = calendarOptionOverrides.buttonHints || {}; + let calendarButtonHints = calendarOptions.buttonHints || {}; + let sectionSubstrs = sectionStr ? sectionStr.split(' ') : []; + let viewsWithButtons = []; + let hasTitle = false; + let widgets = sectionSubstrs.map((buttonGroupStr) => (buttonGroupStr.split(',').map((buttonName) => { + if (buttonName === 'title') { + hasTitle = true; + return { buttonName }; + } + let customButtonProps; + let viewSpec; + let buttonClick; + let buttonIcon; // only one of these will be set + let buttonText; // " + let buttonHint; + // ^ for the title="" attribute, for accessibility + if ((customButtonProps = calendarCustomButtons[buttonName])) { + buttonClick = (ev) => { + if (customButtonProps.click) { + customButtonProps.click.call(ev.target, ev, ev.target); // TODO: use Calendar this context? + } + }; + (buttonIcon = theme.getCustomButtonIconClass(customButtonProps)) || + (buttonIcon = theme.getIconClass(buttonName, isRtl)) || + (buttonText = customButtonProps.text); + buttonHint = customButtonProps.hint || customButtonProps.text; + } + else if ((viewSpec = viewSpecs[buttonName])) { + viewsWithButtons.push(buttonName); + buttonClick = () => { + calendarApi.changeView(buttonName); + }; + (buttonText = viewSpec.buttonTextOverride) || + (buttonIcon = theme.getIconClass(buttonName, isRtl)) || + (buttonText = viewSpec.buttonTextDefault); + let textFallback = viewSpec.buttonTextOverride || + viewSpec.buttonTextDefault; + buttonHint = internalCommon.formatWithOrdinals(viewSpec.buttonTitleOverride || + viewSpec.buttonTitleDefault || + calendarOptions.viewHint, [textFallback, buttonName], // view-name = buttonName + textFallback); + } + else if (calendarApi[buttonName]) { // a calendarApi method + buttonClick = () => { + calendarApi[buttonName](); + }; + (buttonText = calendarButtonTextOverrides[buttonName]) || + (buttonIcon = theme.getIconClass(buttonName, isRtl)) || + (buttonText = calendarButtonText[buttonName]); // everything else is considered default + if (buttonName === 'prevYear' || buttonName === 'nextYear') { + let prevOrNext = buttonName === 'prevYear' ? 'prev' : 'next'; + buttonHint = internalCommon.formatWithOrdinals(calendarButtonHintOverrides[prevOrNext] || + calendarButtonHints[prevOrNext], [ + calendarButtonText.year || 'year', + 'year', + ], calendarButtonText[buttonName]); + } + else { + buttonHint = (navUnit) => internalCommon.formatWithOrdinals(calendarButtonHintOverrides[buttonName] || + calendarButtonHints[buttonName], [ + calendarButtonText[navUnit] || navUnit, + navUnit, + ], calendarButtonText[buttonName]); + } + } + return { buttonName, buttonClick, buttonIcon, buttonText, buttonHint }; + }))); + return { widgets, viewsWithButtons, hasTitle }; +} + +// always represents the current view. otherwise, it'd need to change value every time date changes +class ViewImpl { + constructor(type, getCurrentData, dateEnv) { + this.type = type; + this.getCurrentData = getCurrentData; + this.dateEnv = dateEnv; + } + get calendar() { + return this.getCurrentData().calendarApi; + } + get title() { + return this.getCurrentData().viewTitle; + } + get activeStart() { + return this.dateEnv.toDate(this.getCurrentData().dateProfile.activeRange.start); + } + get activeEnd() { + return this.dateEnv.toDate(this.getCurrentData().dateProfile.activeRange.end); + } + get currentStart() { + return this.dateEnv.toDate(this.getCurrentData().dateProfile.currentRange.start); + } + get currentEnd() { + return this.dateEnv.toDate(this.getCurrentData().dateProfile.currentRange.end); + } + getOption(name) { + return this.getCurrentData().options[name]; // are the view-specific options + } +} + +let eventSourceDef$2 = { + ignoreRange: true, + parseMeta(refined) { + if (Array.isArray(refined.events)) { + return refined.events; + } + return null; + }, + fetch(arg, successCallback) { + successCallback({ + rawEvents: arg.eventSource.meta, + }); + }, +}; +const arrayEventSourcePlugin = createPlugin({ + name: 'array-event-source', + eventSourceDefs: [eventSourceDef$2], +}); + +let eventSourceDef$1 = { + parseMeta(refined) { + if (typeof refined.events === 'function') { + return refined.events; + } + return null; + }, + fetch(arg, successCallback, errorCallback) { + const { dateEnv } = arg.context; + const func = arg.eventSource.meta; + internalCommon.unpromisify(func.bind(null, internalCommon.buildRangeApiWithTimeZone(arg.range, dateEnv)), (rawEvents) => successCallback({ rawEvents }), errorCallback); + }, +}; +const funcEventSourcePlugin = createPlugin({ + name: 'func-event-source', + eventSourceDefs: [eventSourceDef$1], +}); + +const JSON_FEED_EVENT_SOURCE_REFINERS = { + method: String, + extraParams: internalCommon.identity, + startParam: String, + endParam: String, + timeZoneParam: String, +}; + +let eventSourceDef = { + parseMeta(refined) { + if (refined.url && (refined.format === 'json' || !refined.format)) { + return { + url: refined.url, + format: 'json', + method: (refined.method || 'GET').toUpperCase(), + extraParams: refined.extraParams, + startParam: refined.startParam, + endParam: refined.endParam, + timeZoneParam: refined.timeZoneParam, + }; + } + return null; + }, + fetch(arg, successCallback, errorCallback) { + const { meta } = arg.eventSource; + const requestParams = buildRequestParams(meta, arg.range, arg.context); + internalCommon.requestJson(meta.method, meta.url, requestParams).then(([rawEvents, response]) => { + successCallback({ rawEvents, response }); + }, errorCallback); + }, +}; +const jsonFeedEventSourcePlugin = createPlugin({ + name: 'json-event-source', + eventSourceRefiners: JSON_FEED_EVENT_SOURCE_REFINERS, + eventSourceDefs: [eventSourceDef], +}); +function buildRequestParams(meta, range, context) { + let { dateEnv, options } = context; + let startParam; + let endParam; + let timeZoneParam; + let customRequestParams; + let params = {}; + startParam = meta.startParam; + if (startParam == null) { + startParam = options.startParam; + } + endParam = meta.endParam; + if (endParam == null) { + endParam = options.endParam; + } + timeZoneParam = meta.timeZoneParam; + if (timeZoneParam == null) { + timeZoneParam = options.timeZoneParam; + } + // retrieve any outbound GET/POST data from the options + if (typeof meta.extraParams === 'function') { + // supplied as a function that returns a key/value object + customRequestParams = meta.extraParams(); + } + else { + // probably supplied as a straight key/value object + customRequestParams = meta.extraParams || {}; + } + Object.assign(params, customRequestParams); + params[startParam] = dateEnv.formatIso(range.start); + params[endParam] = dateEnv.formatIso(range.end); + if (dateEnv.timeZone !== 'local') { + params[timeZoneParam] = dateEnv.timeZone; + } + return params; +} + +const SIMPLE_RECURRING_REFINERS = { + daysOfWeek: internalCommon.identity, + startTime: internalCommon.createDuration, + endTime: internalCommon.createDuration, + duration: internalCommon.createDuration, + startRecur: internalCommon.identity, + endRecur: internalCommon.identity, +}; + +let recurring = { + parse(refined, dateEnv) { + if (refined.daysOfWeek || refined.startTime || refined.endTime || refined.startRecur || refined.endRecur) { + let recurringData = { + daysOfWeek: refined.daysOfWeek || null, + startTime: refined.startTime || null, + endTime: refined.endTime || null, + startRecur: refined.startRecur ? dateEnv.createMarker(refined.startRecur) : null, + endRecur: refined.endRecur ? dateEnv.createMarker(refined.endRecur) : null, + }; + let duration; + if (refined.duration) { + duration = refined.duration; + } + if (!duration && refined.startTime && refined.endTime) { + duration = internalCommon.subtractDurations(refined.endTime, refined.startTime); + } + return { + allDayGuess: Boolean(!refined.startTime && !refined.endTime), + duration, + typeData: recurringData, // doesn't need endTime anymore but oh well + }; + } + return null; + }, + expand(typeData, framingRange, dateEnv) { + let clippedFramingRange = internalCommon.intersectRanges(framingRange, { start: typeData.startRecur, end: typeData.endRecur }); + if (clippedFramingRange) { + return expandRanges(typeData.daysOfWeek, typeData.startTime, clippedFramingRange, dateEnv); + } + return []; + }, +}; +const simpleRecurringEventsPlugin = createPlugin({ + name: 'simple-recurring-event', + recurringTypes: [recurring], + eventRefiners: SIMPLE_RECURRING_REFINERS, +}); +function expandRanges(daysOfWeek, startTime, framingRange, dateEnv) { + let dowHash = daysOfWeek ? internalCommon.arrayToHash(daysOfWeek) : null; + let dayMarker = internalCommon.startOfDay(framingRange.start); + let endMarker = framingRange.end; + let instanceStarts = []; + while (dayMarker < endMarker) { + let instanceStart; + // if everyday, or this particular day-of-week + if (!dowHash || dowHash[dayMarker.getUTCDay()]) { + if (startTime) { + instanceStart = dateEnv.add(dayMarker, startTime); + } + else { + instanceStart = dayMarker; + } + instanceStarts.push(instanceStart); + } + dayMarker = internalCommon.addDays(dayMarker, 1); + } + return instanceStarts; +} + +const changeHandlerPlugin = createPlugin({ + name: 'change-handler', + optionChangeHandlers: { + events(events, context) { + handleEventSources([events], context); + }, + eventSources: handleEventSources, + }, +}); +/* +BUG: if `event` was supplied, all previously-given `eventSources` will be wiped out +*/ +function handleEventSources(inputs, context) { + let unfoundSources = internalCommon.hashValuesToArray(context.getCurrentData().eventSources); + if (unfoundSources.length === 1 && + inputs.length === 1 && + Array.isArray(unfoundSources[0]._raw) && + Array.isArray(inputs[0])) { + context.dispatch({ + type: 'RESET_RAW_EVENTS', + sourceId: unfoundSources[0].sourceId, + rawEvents: inputs[0], + }); + return; + } + let newInputs = []; + for (let input of inputs) { + let inputFound = false; + for (let i = 0; i < unfoundSources.length; i += 1) { + if (unfoundSources[i]._raw === input) { + unfoundSources.splice(i, 1); // delete + inputFound = true; + break; + } + } + if (!inputFound) { + newInputs.push(input); + } + } + for (let unfoundSource of unfoundSources) { + context.dispatch({ + type: 'REMOVE_EVENT_SOURCE', + sourceId: unfoundSource.sourceId, + }); + } + for (let newInput of newInputs) { + context.calendarApi.addEventSource(newInput); + } +} + +function handleDateProfile(dateProfile, context) { + context.emitter.trigger('datesSet', Object.assign(Object.assign({}, internalCommon.buildRangeApiWithTimeZone(dateProfile.activeRange, context.dateEnv)), { view: context.viewApi })); +} + +function handleEventStore(eventStore, context) { + let { emitter } = context; + if (emitter.hasHandlers('eventsSet')) { + emitter.trigger('eventsSet', internalCommon.buildEventApis(eventStore, context)); + } +} + +/* +this array is exposed on the root namespace so that UMD plugins can add to it. +see the rollup-bundles script. +*/ +const globalPlugins = [ + arrayEventSourcePlugin, + funcEventSourcePlugin, + jsonFeedEventSourcePlugin, + simpleRecurringEventsPlugin, + changeHandlerPlugin, + createPlugin({ + name: 'misc', + isLoadingFuncs: [ + (state) => computeEventSourcesLoading(state.eventSources), + ], + propSetHandlers: { + dateProfile: handleDateProfile, + eventStore: handleEventStore, + }, + }), +]; + +class TaskRunner { + constructor(runTaskOption, drainedOption) { + this.runTaskOption = runTaskOption; + this.drainedOption = drainedOption; + this.queue = []; + this.delayedRunner = new internalCommon.DelayedRunner(this.drain.bind(this)); + } + request(task, delay) { + this.queue.push(task); + this.delayedRunner.request(delay); + } + pause(scope) { + this.delayedRunner.pause(scope); + } + resume(scope, force) { + this.delayedRunner.resume(scope, force); + } + drain() { + let { queue } = this; + while (queue.length) { + let completedTasks = []; + let task; + while ((task = queue.shift())) { + this.runTask(task); + completedTasks.push(task); + } + this.drained(completedTasks); + } // keep going, in case new tasks were added in the drained handler + } + runTask(task) { + if (this.runTaskOption) { + this.runTaskOption(task); + } + } + drained(completedTasks) { + if (this.drainedOption) { + this.drainedOption(completedTasks); + } + } +} + +// Computes what the title at the top of the calendarApi should be for this view +function buildTitle(dateProfile, viewOptions, dateEnv) { + let range; + // for views that span a large unit of time, show the proper interval, ignoring stray days before and after + if (/^(year|month)$/.test(dateProfile.currentRangeUnit)) { + range = dateProfile.currentRange; + } + else { // for day units or smaller, use the actual day range + range = dateProfile.activeRange; + } + return dateEnv.formatRange(range.start, range.end, internalCommon.createFormatter(viewOptions.titleFormat || buildTitleFormat(dateProfile)), { + isEndExclusive: dateProfile.isRangeAllDay, + defaultSeparator: viewOptions.titleRangeSeparator, + }); +} +// Generates the format string that should be used to generate the title for the current date range. +// Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`. +function buildTitleFormat(dateProfile) { + let { currentRangeUnit } = dateProfile; + if (currentRangeUnit === 'year') { + return { year: 'numeric' }; + } + if (currentRangeUnit === 'month') { + return { year: 'numeric', month: 'long' }; // like "September 2014" + } + let days = internalCommon.diffWholeDays(dateProfile.currentRange.start, dateProfile.currentRange.end); + if (days !== null && days > 1) { + // multi-day range. shorter, like "Sep 9 - 10 2014" + return { year: 'numeric', month: 'short', day: 'numeric' }; + } + // one day. longer, like "September 9 2014" + return { year: 'numeric', month: 'long', day: 'numeric' }; +} + +// in future refactor, do the redux-style function(state=initial) for initial-state +// also, whatever is happening in constructor, have it happen in action queue too +class CalendarDataManager { + constructor(props) { + this.computeCurrentViewData = internalCommon.memoize(this._computeCurrentViewData); + this.organizeRawLocales = internalCommon.memoize(organizeRawLocales); + this.buildLocale = internalCommon.memoize(buildLocale); + this.buildPluginHooks = buildBuildPluginHooks(); + this.buildDateEnv = internalCommon.memoize(buildDateEnv$1); + this.buildTheme = internalCommon.memoize(buildTheme); + this.parseToolbars = internalCommon.memoize(parseToolbars); + this.buildViewSpecs = internalCommon.memoize(buildViewSpecs); + this.buildDateProfileGenerator = internalCommon.memoizeObjArg(buildDateProfileGenerator); + this.buildViewApi = internalCommon.memoize(buildViewApi); + this.buildViewUiProps = internalCommon.memoizeObjArg(buildViewUiProps); + this.buildEventUiBySource = internalCommon.memoize(buildEventUiBySource, internalCommon.isPropsEqual); + this.buildEventUiBases = internalCommon.memoize(buildEventUiBases); + this.parseContextBusinessHours = internalCommon.memoizeObjArg(parseContextBusinessHours); + this.buildTitle = internalCommon.memoize(buildTitle); + this.emitter = new internalCommon.Emitter(); + this.actionRunner = new TaskRunner(this._handleAction.bind(this), this.updateData.bind(this)); + this.currentCalendarOptionsInput = {}; + this.currentCalendarOptionsRefined = {}; + this.currentViewOptionsInput = {}; + this.currentViewOptionsRefined = {}; + this.currentCalendarOptionsRefiners = {}; + this.optionsForRefining = []; + this.optionsForHandling = []; + this.getCurrentData = () => this.data; + this.dispatch = (action) => { + this.actionRunner.request(action); // protects against recursive calls to _handleAction + }; + this.props = props; + this.actionRunner.pause(); + let dynamicOptionOverrides = {}; + let optionsData = this.computeOptionsData(props.optionOverrides, dynamicOptionOverrides, props.calendarApi); + let currentViewType = optionsData.calendarOptions.initialView || optionsData.pluginHooks.initialView; + let currentViewData = this.computeCurrentViewData(currentViewType, optionsData, props.optionOverrides, dynamicOptionOverrides); + // wire things up + // TODO: not DRY + props.calendarApi.currentDataManager = this; + this.emitter.setThisContext(props.calendarApi); + this.emitter.setOptions(currentViewData.options); + let currentDate = internalCommon.getInitialDate(optionsData.calendarOptions, optionsData.dateEnv); + let dateProfile = currentViewData.dateProfileGenerator.build(currentDate); + if (!internalCommon.rangeContainsMarker(dateProfile.activeRange, currentDate)) { + currentDate = dateProfile.currentRange.start; + } + let calendarContext = { + dateEnv: optionsData.dateEnv, + options: optionsData.calendarOptions, + pluginHooks: optionsData.pluginHooks, + calendarApi: props.calendarApi, + dispatch: this.dispatch, + emitter: this.emitter, + getCurrentData: this.getCurrentData, + }; + // needs to be after setThisContext + for (let callback of optionsData.pluginHooks.contextInit) { + callback(calendarContext); + } + // NOT DRY + let eventSources = initEventSources(optionsData.calendarOptions, dateProfile, calendarContext); + let initialState = { + dynamicOptionOverrides, + currentViewType, + currentDate, + dateProfile, + businessHours: this.parseContextBusinessHours(calendarContext), + eventSources, + eventUiBases: {}, + eventStore: internalCommon.createEmptyEventStore(), + renderableEventStore: internalCommon.createEmptyEventStore(), + dateSelection: null, + eventSelection: '', + eventDrag: null, + eventResize: null, + selectionConfig: this.buildViewUiProps(calendarContext).selectionConfig, + }; + let contextAndState = Object.assign(Object.assign({}, calendarContext), initialState); + for (let reducer of optionsData.pluginHooks.reducers) { + Object.assign(initialState, reducer(null, null, contextAndState)); + } + if (computeIsLoading(initialState, calendarContext)) { + this.emitter.trigger('loading', true); // NOT DRY + } + this.state = initialState; + this.updateData(); + this.actionRunner.resume(); + } + resetOptions(optionOverrides, changedOptionNames) { + let { props } = this; + if (changedOptionNames === undefined) { + props.optionOverrides = optionOverrides; + } + else { + props.optionOverrides = Object.assign(Object.assign({}, (props.optionOverrides || {})), optionOverrides); + this.optionsForRefining.push(...changedOptionNames); + } + if (changedOptionNames === undefined || changedOptionNames.length) { + this.actionRunner.request({ + type: 'NOTHING', + }); + } + } + _handleAction(action) { + let { props, state, emitter } = this; + let dynamicOptionOverrides = reduceDynamicOptionOverrides(state.dynamicOptionOverrides, action); + let optionsData = this.computeOptionsData(props.optionOverrides, dynamicOptionOverrides, props.calendarApi); + let currentViewType = reduceViewType(state.currentViewType, action); + let currentViewData = this.computeCurrentViewData(currentViewType, optionsData, props.optionOverrides, dynamicOptionOverrides); + // wire things up + // TODO: not DRY + props.calendarApi.currentDataManager = this; + emitter.setThisContext(props.calendarApi); + emitter.setOptions(currentViewData.options); + let calendarContext = { + dateEnv: optionsData.dateEnv, + options: optionsData.calendarOptions, + pluginHooks: optionsData.pluginHooks, + calendarApi: props.calendarApi, + dispatch: this.dispatch, + emitter, + getCurrentData: this.getCurrentData, + }; + let { currentDate, dateProfile } = state; + if (this.data && this.data.dateProfileGenerator !== currentViewData.dateProfileGenerator) { // hack + dateProfile = currentViewData.dateProfileGenerator.build(currentDate); + } + currentDate = internalCommon.reduceCurrentDate(currentDate, action); + dateProfile = reduceDateProfile(dateProfile, action, currentDate, currentViewData.dateProfileGenerator); + if (action.type === 'PREV' || // TODO: move this logic into DateProfileGenerator + action.type === 'NEXT' || // " + !internalCommon.rangeContainsMarker(dateProfile.currentRange, currentDate)) { + currentDate = dateProfile.currentRange.start; + } + let eventSources = reduceEventSources(state.eventSources, action, dateProfile, calendarContext); + let eventStore = internalCommon.reduceEventStore(state.eventStore, action, eventSources, dateProfile, calendarContext); + let isEventsLoading = computeEventSourcesLoading(eventSources); // BAD. also called in this func in computeIsLoading + let renderableEventStore = (isEventsLoading && !currentViewData.options.progressiveEventRendering) ? + (state.renderableEventStore || eventStore) : // try from previous state + eventStore; + let { eventUiSingleBase, selectionConfig } = this.buildViewUiProps(calendarContext); // will memoize obj + let eventUiBySource = this.buildEventUiBySource(eventSources); + let eventUiBases = this.buildEventUiBases(renderableEventStore.defs, eventUiSingleBase, eventUiBySource); + let newState = { + dynamicOptionOverrides, + currentViewType, + currentDate, + dateProfile, + eventSources, + eventStore, + renderableEventStore, + selectionConfig, + eventUiBases, + businessHours: this.parseContextBusinessHours(calendarContext), + dateSelection: reduceDateSelection(state.dateSelection, action), + eventSelection: reduceSelectedEvent(state.eventSelection, action), + eventDrag: reduceEventDrag(state.eventDrag, action), + eventResize: reduceEventResize(state.eventResize, action), + }; + let contextAndState = Object.assign(Object.assign({}, calendarContext), newState); + for (let reducer of optionsData.pluginHooks.reducers) { + Object.assign(newState, reducer(state, action, contextAndState)); // give the OLD state, for old value + } + let wasLoading = computeIsLoading(state, calendarContext); + let isLoading = computeIsLoading(newState, calendarContext); + // TODO: use propSetHandlers in plugin system + if (!wasLoading && isLoading) { + emitter.trigger('loading', true); + } + else if (wasLoading && !isLoading) { + emitter.trigger('loading', false); + } + this.state = newState; + if (props.onAction) { + props.onAction(action); + } + } + updateData() { + let { props, state } = this; + let oldData = this.data; + let optionsData = this.computeOptionsData(props.optionOverrides, state.dynamicOptionOverrides, props.calendarApi); + let currentViewData = this.computeCurrentViewData(state.currentViewType, optionsData, props.optionOverrides, state.dynamicOptionOverrides); + let data = this.data = Object.assign(Object.assign(Object.assign({ viewTitle: this.buildTitle(state.dateProfile, currentViewData.options, optionsData.dateEnv), calendarApi: props.calendarApi, dispatch: this.dispatch, emitter: this.emitter, getCurrentData: this.getCurrentData }, optionsData), currentViewData), state); + let changeHandlers = optionsData.pluginHooks.optionChangeHandlers; + let oldCalendarOptions = oldData && oldData.calendarOptions; + let newCalendarOptions = optionsData.calendarOptions; + if (oldCalendarOptions && oldCalendarOptions !== newCalendarOptions) { + if (oldCalendarOptions.timeZone !== newCalendarOptions.timeZone) { + // hack + state.eventSources = data.eventSources = reduceEventSourcesNewTimeZone(data.eventSources, state.dateProfile, data); + state.eventStore = data.eventStore = internalCommon.rezoneEventStoreDates(data.eventStore, oldData.dateEnv, data.dateEnv); + state.renderableEventStore = data.renderableEventStore = internalCommon.rezoneEventStoreDates(data.renderableEventStore, oldData.dateEnv, data.dateEnv); + } + for (let optionName in changeHandlers) { + if (this.optionsForHandling.indexOf(optionName) !== -1 || + oldCalendarOptions[optionName] !== newCalendarOptions[optionName]) { + changeHandlers[optionName](newCalendarOptions[optionName], data); + } + } + } + this.optionsForHandling = []; + if (props.onData) { + props.onData(data); + } + } + computeOptionsData(optionOverrides, dynamicOptionOverrides, calendarApi) { + // TODO: blacklist options that are handled by optionChangeHandlers + if (!this.optionsForRefining.length && + optionOverrides === this.stableOptionOverrides && + dynamicOptionOverrides === this.stableDynamicOptionOverrides) { + return this.stableCalendarOptionsData; + } + let { refinedOptions, pluginHooks, localeDefaults, availableLocaleData, extra, } = this.processRawCalendarOptions(optionOverrides, dynamicOptionOverrides); + warnUnknownOptions(extra); + let dateEnv = this.buildDateEnv(refinedOptions.timeZone, refinedOptions.locale, refinedOptions.weekNumberCalculation, refinedOptions.firstDay, refinedOptions.weekText, pluginHooks, availableLocaleData, refinedOptions.defaultRangeSeparator); + let viewSpecs = this.buildViewSpecs(pluginHooks.views, this.stableOptionOverrides, this.stableDynamicOptionOverrides, localeDefaults); + let theme = this.buildTheme(refinedOptions, pluginHooks); + let toolbarConfig = this.parseToolbars(refinedOptions, this.stableOptionOverrides, theme, viewSpecs, calendarApi); + return this.stableCalendarOptionsData = { + calendarOptions: refinedOptions, + pluginHooks, + dateEnv, + viewSpecs, + theme, + toolbarConfig, + localeDefaults, + availableRawLocales: availableLocaleData.map, + }; + } + // always called from behind a memoizer + processRawCalendarOptions(optionOverrides, dynamicOptionOverrides) { + let { locales, locale } = internalCommon.mergeRawOptions([ + internalCommon.BASE_OPTION_DEFAULTS, + optionOverrides, + dynamicOptionOverrides, + ]); + let availableLocaleData = this.organizeRawLocales(locales); + let availableRawLocales = availableLocaleData.map; + let localeDefaults = this.buildLocale(locale || availableLocaleData.defaultCode, availableRawLocales).options; + let pluginHooks = this.buildPluginHooks(optionOverrides.plugins || [], globalPlugins); + let refiners = this.currentCalendarOptionsRefiners = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, internalCommon.BASE_OPTION_REFINERS), internalCommon.CALENDAR_LISTENER_REFINERS), internalCommon.CALENDAR_OPTION_REFINERS), pluginHooks.listenerRefiners), pluginHooks.optionRefiners); + let extra = {}; + let raw = internalCommon.mergeRawOptions([ + internalCommon.BASE_OPTION_DEFAULTS, + localeDefaults, + optionOverrides, + dynamicOptionOverrides, + ]); + let refined = {}; + let currentRaw = this.currentCalendarOptionsInput; + let currentRefined = this.currentCalendarOptionsRefined; + let anyChanges = false; + for (let optionName in raw) { + if (this.optionsForRefining.indexOf(optionName) === -1 && (raw[optionName] === currentRaw[optionName] || (internalCommon.COMPLEX_OPTION_COMPARATORS[optionName] && + (optionName in currentRaw) && + internalCommon.COMPLEX_OPTION_COMPARATORS[optionName](currentRaw[optionName], raw[optionName])))) { + refined[optionName] = currentRefined[optionName]; + } + else if (refiners[optionName]) { + refined[optionName] = refiners[optionName](raw[optionName]); + anyChanges = true; + } + else { + extra[optionName] = currentRaw[optionName]; + } + } + if (anyChanges) { + this.currentCalendarOptionsInput = raw; + this.currentCalendarOptionsRefined = refined; + this.stableOptionOverrides = optionOverrides; + this.stableDynamicOptionOverrides = dynamicOptionOverrides; + } + this.optionsForHandling.push(...this.optionsForRefining); + this.optionsForRefining = []; + return { + rawOptions: this.currentCalendarOptionsInput, + refinedOptions: this.currentCalendarOptionsRefined, + pluginHooks, + availableLocaleData, + localeDefaults, + extra, + }; + } + _computeCurrentViewData(viewType, optionsData, optionOverrides, dynamicOptionOverrides) { + let viewSpec = optionsData.viewSpecs[viewType]; + if (!viewSpec) { + throw new Error(`viewType "${viewType}" is not available. Please make sure you've loaded all neccessary plugins`); + } + let { refinedOptions, extra } = this.processRawViewOptions(viewSpec, optionsData.pluginHooks, optionsData.localeDefaults, optionOverrides, dynamicOptionOverrides); + warnUnknownOptions(extra); + let dateProfileGenerator = this.buildDateProfileGenerator({ + dateProfileGeneratorClass: viewSpec.optionDefaults.dateProfileGeneratorClass, + duration: viewSpec.duration, + durationUnit: viewSpec.durationUnit, + usesMinMaxTime: viewSpec.optionDefaults.usesMinMaxTime, + dateEnv: optionsData.dateEnv, + calendarApi: this.props.calendarApi, + slotMinTime: refinedOptions.slotMinTime, + slotMaxTime: refinedOptions.slotMaxTime, + showNonCurrentDates: refinedOptions.showNonCurrentDates, + dayCount: refinedOptions.dayCount, + dateAlignment: refinedOptions.dateAlignment, + dateIncrement: refinedOptions.dateIncrement, + hiddenDays: refinedOptions.hiddenDays, + weekends: refinedOptions.weekends, + nowInput: refinedOptions.now, + validRangeInput: refinedOptions.validRange, + visibleRangeInput: refinedOptions.visibleRange, + fixedWeekCount: refinedOptions.fixedWeekCount, + }); + let viewApi = this.buildViewApi(viewType, this.getCurrentData, optionsData.dateEnv); + return { viewSpec, options: refinedOptions, dateProfileGenerator, viewApi }; + } + processRawViewOptions(viewSpec, pluginHooks, localeDefaults, optionOverrides, dynamicOptionOverrides) { + let raw = internalCommon.mergeRawOptions([ + internalCommon.BASE_OPTION_DEFAULTS, + viewSpec.optionDefaults, + localeDefaults, + optionOverrides, + viewSpec.optionOverrides, + dynamicOptionOverrides, + ]); + let refiners = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, internalCommon.BASE_OPTION_REFINERS), internalCommon.CALENDAR_LISTENER_REFINERS), internalCommon.CALENDAR_OPTION_REFINERS), internalCommon.VIEW_OPTION_REFINERS), pluginHooks.listenerRefiners), pluginHooks.optionRefiners); + let refined = {}; + let currentRaw = this.currentViewOptionsInput; + let currentRefined = this.currentViewOptionsRefined; + let anyChanges = false; + let extra = {}; + for (let optionName in raw) { + if (raw[optionName] === currentRaw[optionName] || + (internalCommon.COMPLEX_OPTION_COMPARATORS[optionName] && + internalCommon.COMPLEX_OPTION_COMPARATORS[optionName](raw[optionName], currentRaw[optionName]))) { + refined[optionName] = currentRefined[optionName]; + } + else { + if (raw[optionName] === this.currentCalendarOptionsInput[optionName] || + (internalCommon.COMPLEX_OPTION_COMPARATORS[optionName] && + internalCommon.COMPLEX_OPTION_COMPARATORS[optionName](raw[optionName], this.currentCalendarOptionsInput[optionName]))) { + if (optionName in this.currentCalendarOptionsRefined) { // might be an "extra" prop + refined[optionName] = this.currentCalendarOptionsRefined[optionName]; + } + } + else if (refiners[optionName]) { + refined[optionName] = refiners[optionName](raw[optionName]); + } + else { + extra[optionName] = raw[optionName]; + } + anyChanges = true; + } + } + if (anyChanges) { + this.currentViewOptionsInput = raw; + this.currentViewOptionsRefined = refined; + } + return { + rawOptions: this.currentViewOptionsInput, + refinedOptions: this.currentViewOptionsRefined, + extra, + }; + } +} +function buildDateEnv$1(timeZone, explicitLocale, weekNumberCalculation, firstDay, weekText, pluginHooks, availableLocaleData, defaultSeparator) { + let locale = buildLocale(explicitLocale || availableLocaleData.defaultCode, availableLocaleData.map); + return new internalCommon.DateEnv({ + calendarSystem: 'gregory', + timeZone, + namedTimeZoneImpl: pluginHooks.namedTimeZonedImpl, + locale, + weekNumberCalculation, + firstDay, + weekText, + cmdFormatter: pluginHooks.cmdFormatter, + defaultSeparator, + }); +} +function buildTheme(options, pluginHooks) { + let ThemeClass = pluginHooks.themeClasses[options.themeSystem] || StandardTheme; + return new ThemeClass(options); +} +function buildDateProfileGenerator(props) { + let DateProfileGeneratorClass = props.dateProfileGeneratorClass || internalCommon.DateProfileGenerator; + return new DateProfileGeneratorClass(props); +} +function buildViewApi(type, getCurrentData, dateEnv) { + return new ViewImpl(type, getCurrentData, dateEnv); +} +function buildEventUiBySource(eventSources) { + return internalCommon.mapHash(eventSources, (eventSource) => eventSource.ui); +} +function buildEventUiBases(eventDefs, eventUiSingleBase, eventUiBySource) { + let eventUiBases = { '': eventUiSingleBase }; + for (let defId in eventDefs) { + let def = eventDefs[defId]; + if (def.sourceId && eventUiBySource[def.sourceId]) { + eventUiBases[defId] = eventUiBySource[def.sourceId]; + } + } + return eventUiBases; +} +function buildViewUiProps(calendarContext) { + let { options } = calendarContext; + return { + eventUiSingleBase: internalCommon.createEventUi({ + display: options.eventDisplay, + editable: options.editable, + startEditable: options.eventStartEditable, + durationEditable: options.eventDurationEditable, + constraint: options.eventConstraint, + overlap: typeof options.eventOverlap === 'boolean' ? options.eventOverlap : undefined, + allow: options.eventAllow, + backgroundColor: options.eventBackgroundColor, + borderColor: options.eventBorderColor, + textColor: options.eventTextColor, + color: options.eventColor, + // classNames: options.eventClassNames // render hook will handle this + }, calendarContext), + selectionConfig: internalCommon.createEventUi({ + constraint: options.selectConstraint, + overlap: typeof options.selectOverlap === 'boolean' ? options.selectOverlap : undefined, + allow: options.selectAllow, + }, calendarContext), + }; +} +function computeIsLoading(state, context) { + for (let isLoadingFunc of context.pluginHooks.isLoadingFuncs) { + if (isLoadingFunc(state)) { + return true; + } + } + return false; +} +function parseContextBusinessHours(calendarContext) { + return internalCommon.parseBusinessHours(calendarContext.options.businessHours, calendarContext); +} +function warnUnknownOptions(options, viewName) { + for (let optionName in options) { + console.warn(`Unknown option '${optionName}'` + + (viewName ? ` for view '${viewName}'` : '')); + } +} + +class ToolbarSection extends internalCommon.BaseComponent { + render() { + let children = this.props.widgetGroups.map((widgetGroup) => this.renderWidgetGroup(widgetGroup)); + return preact.createElement('div', { className: 'fc-toolbar-chunk' }, ...children); + } + renderWidgetGroup(widgetGroup) { + let { props } = this; + let { theme } = this.context; + let children = []; + let isOnlyButtons = true; + for (let widget of widgetGroup) { + let { buttonName, buttonClick, buttonText, buttonIcon, buttonHint } = widget; + if (buttonName === 'title') { + isOnlyButtons = false; + children.push(preact.createElement("h2", { className: "fc-toolbar-title", id: props.titleId }, props.title)); + } + else { + let isPressed = buttonName === props.activeButton; + let isDisabled = (!props.isTodayEnabled && buttonName === 'today') || + (!props.isPrevEnabled && buttonName === 'prev') || + (!props.isNextEnabled && buttonName === 'next'); + let buttonClasses = [`fc-${buttonName}-button`, theme.getClass('button')]; + if (isPressed) { + buttonClasses.push(theme.getClass('buttonActive')); + } + children.push(preact.createElement("button", { type: "button", title: typeof buttonHint === 'function' ? buttonHint(props.navUnit) : buttonHint, disabled: isDisabled, "aria-pressed": isPressed, className: buttonClasses.join(' '), onClick: buttonClick }, buttonText || (buttonIcon ? preact.createElement("span", { className: buttonIcon, role: "img" }) : ''))); + } + } + if (children.length > 1) { + let groupClassName = (isOnlyButtons && theme.getClass('buttonGroup')) || ''; + return preact.createElement('div', { className: groupClassName }, ...children); + } + return children[0]; + } +} + +class Toolbar extends internalCommon.BaseComponent { + render() { + let { model, extraClassName } = this.props; + let forceLtr = false; + let startContent; + let endContent; + let sectionWidgets = model.sectionWidgets; + let centerContent = sectionWidgets.center; + if (sectionWidgets.left) { + forceLtr = true; + startContent = sectionWidgets.left; + } + else { + startContent = sectionWidgets.start; + } + if (sectionWidgets.right) { + forceLtr = true; + endContent = sectionWidgets.right; + } + else { + endContent = sectionWidgets.end; + } + let classNames = [ + extraClassName || '', + 'fc-toolbar', + forceLtr ? 'fc-toolbar-ltr' : '', + ]; + return (preact.createElement("div", { className: classNames.join(' ') }, + this.renderSection('start', startContent || []), + this.renderSection('center', centerContent || []), + this.renderSection('end', endContent || []))); + } + renderSection(key, widgetGroups) { + let { props } = this; + return (preact.createElement(ToolbarSection, { key: key, widgetGroups: widgetGroups, title: props.title, navUnit: props.navUnit, activeButton: props.activeButton, isTodayEnabled: props.isTodayEnabled, isPrevEnabled: props.isPrevEnabled, isNextEnabled: props.isNextEnabled, titleId: props.titleId })); + } +} + +class ViewHarness extends internalCommon.BaseComponent { + constructor() { + super(...arguments); + this.state = { + availableWidth: null, + }; + this.handleEl = (el) => { + this.el = el; + internalCommon.setRef(this.props.elRef, el); + this.updateAvailableWidth(); + }; + this.handleResize = () => { + this.updateAvailableWidth(); + }; + } + render() { + let { props, state } = this; + let { aspectRatio } = props; + let classNames = [ + 'fc-view-harness', + (aspectRatio || props.liquid || props.height) + ? 'fc-view-harness-active' // harness controls the height + : 'fc-view-harness-passive', // let the view do the height + ]; + let height = ''; + let paddingBottom = ''; + if (aspectRatio) { + if (state.availableWidth !== null) { + height = state.availableWidth / aspectRatio; + } + else { + // while waiting to know availableWidth, we can't set height to *zero* + // because will cause lots of unnecessary scrollbars within scrollgrid. + // BETTER: don't start rendering ANYTHING yet until we know container width + // NOTE: why not always use paddingBottom? Causes height oscillation (issue 5606) + paddingBottom = `${(1 / aspectRatio) * 100}%`; + } + } + else { + height = props.height || ''; + } + return (preact.createElement("div", { "aria-labelledby": props.labeledById, ref: this.handleEl, className: classNames.join(' '), style: { height, paddingBottom } }, props.children)); + } + componentDidMount() { + this.context.addResizeHandler(this.handleResize); + } + componentWillUnmount() { + this.context.removeResizeHandler(this.handleResize); + } + updateAvailableWidth() { + if (this.el && // needed. but why? + this.props.aspectRatio // aspectRatio is the only height setting that needs availableWidth + ) { + this.setState({ availableWidth: this.el.offsetWidth }); + } + } +} + +/* +Detects when the user clicks on an event within a DateComponent +*/ +class EventClicking extends internalCommon.Interaction { + constructor(settings) { + super(settings); + this.handleSegClick = (ev, segEl) => { + let { component } = this; + let { context } = component; + let seg = internalCommon.getElSeg(segEl); + if (seg && // might be the
surrounding the more link + component.isValidSegDownEl(ev.target)) { + // our way to simulate a link click for elements that can't be tags + // grab before trigger fired in case trigger trashes DOM thru rerendering + let hasUrlContainer = internalCommon.elementClosest(ev.target, '.fc-event-forced-url'); + let url = hasUrlContainer ? hasUrlContainer.querySelector('a[href]').href : ''; + context.emitter.trigger('eventClick', { + el: segEl, + event: new internalCommon.EventImpl(component.context, seg.eventRange.def, seg.eventRange.instance), + jsEvent: ev, + view: context.viewApi, + }); + if (url && !ev.defaultPrevented) { + window.location.href = url; + } + } + }; + this.destroy = internalCommon.listenBySelector(settings.el, 'click', '.fc-event', // on both fg and bg events + this.handleSegClick); + } +} + +/* +Triggers events and adds/removes core classNames when the user's pointer +enters/leaves event-elements of a component. +*/ +class EventHovering extends internalCommon.Interaction { + constructor(settings) { + super(settings); + // for simulating an eventMouseLeave when the event el is destroyed while mouse is over it + this.handleEventElRemove = (el) => { + if (el === this.currentSegEl) { + this.handleSegLeave(null, this.currentSegEl); + } + }; + this.handleSegEnter = (ev, segEl) => { + if (internalCommon.getElSeg(segEl)) { // TODO: better way to make sure not hovering over more+ link or its wrapper + this.currentSegEl = segEl; + this.triggerEvent('eventMouseEnter', ev, segEl); + } + }; + this.handleSegLeave = (ev, segEl) => { + if (this.currentSegEl) { + this.currentSegEl = null; + this.triggerEvent('eventMouseLeave', ev, segEl); + } + }; + this.removeHoverListeners = internalCommon.listenToHoverBySelector(settings.el, '.fc-event', // on both fg and bg events + this.handleSegEnter, this.handleSegLeave); + } + destroy() { + this.removeHoverListeners(); + } + triggerEvent(publicEvName, ev, segEl) { + let { component } = this; + let { context } = component; + let seg = internalCommon.getElSeg(segEl); + if (!ev || component.isValidSegDownEl(ev.target)) { + context.emitter.trigger(publicEvName, { + el: segEl, + event: new internalCommon.EventImpl(context, seg.eventRange.def, seg.eventRange.instance), + jsEvent: ev, + view: context.viewApi, + }); + } + } +} + +class CalendarContent extends internalCommon.PureComponent { + constructor() { + super(...arguments); + this.buildViewContext = internalCommon.memoize(internalCommon.buildViewContext); + this.buildViewPropTransformers = internalCommon.memoize(buildViewPropTransformers); + this.buildToolbarProps = internalCommon.memoize(buildToolbarProps); + this.headerRef = preact.createRef(); + this.footerRef = preact.createRef(); + this.interactionsStore = {}; + // eslint-disable-next-line + this.state = { + viewLabelId: internalCommon.getUniqueDomId(), + }; + // Component Registration + // ----------------------------------------------------------------------------------------------------------------- + this.registerInteractiveComponent = (component, settingsInput) => { + let settings = internalCommon.parseInteractionSettings(component, settingsInput); + let DEFAULT_INTERACTIONS = [ + EventClicking, + EventHovering, + ]; + let interactionClasses = DEFAULT_INTERACTIONS.concat(this.props.pluginHooks.componentInteractions); + let interactions = interactionClasses.map((TheInteractionClass) => new TheInteractionClass(settings)); + this.interactionsStore[component.uid] = interactions; + internalCommon.interactionSettingsStore[component.uid] = settings; + }; + this.unregisterInteractiveComponent = (component) => { + let listeners = this.interactionsStore[component.uid]; + if (listeners) { + for (let listener of listeners) { + listener.destroy(); + } + delete this.interactionsStore[component.uid]; + } + delete internalCommon.interactionSettingsStore[component.uid]; + }; + // Resizing + // ----------------------------------------------------------------------------------------------------------------- + this.resizeRunner = new internalCommon.DelayedRunner(() => { + this.props.emitter.trigger('_resize', true); // should window resizes be considered "forced" ? + this.props.emitter.trigger('windowResize', { view: this.props.viewApi }); + }); + this.handleWindowResize = (ev) => { + let { options } = this.props; + if (options.handleWindowResize && + ev.target === window // avoid jqui events + ) { + this.resizeRunner.request(options.windowResizeDelay); + } + }; + } + /* + renders INSIDE of an outer div + */ + render() { + let { props } = this; + let { toolbarConfig, options } = props; + let toolbarProps = this.buildToolbarProps(props.viewSpec, props.dateProfile, props.dateProfileGenerator, props.currentDate, internalCommon.getNow(props.options.now, props.dateEnv), // TODO: use NowTimer???? + props.viewTitle); + let viewVGrow = false; + let viewHeight = ''; + let viewAspectRatio; + if (props.isHeightAuto || props.forPrint) { + viewHeight = ''; + } + else if (options.height != null) { + viewVGrow = true; + } + else if (options.contentHeight != null) { + viewHeight = options.contentHeight; + } + else { + viewAspectRatio = Math.max(options.aspectRatio, 0.5); // prevent from getting too tall + } + let viewContext = this.buildViewContext(props.viewSpec, props.viewApi, props.options, props.dateProfileGenerator, props.dateEnv, props.theme, props.pluginHooks, props.dispatch, props.getCurrentData, props.emitter, props.calendarApi, this.registerInteractiveComponent, this.unregisterInteractiveComponent); + let viewLabelId = (toolbarConfig.header && toolbarConfig.header.hasTitle) + ? this.state.viewLabelId + : undefined; + return (preact.createElement(internalCommon.ViewContextType.Provider, { value: viewContext }, + toolbarConfig.header && (preact.createElement(Toolbar, Object.assign({ ref: this.headerRef, extraClassName: "fc-header-toolbar", model: toolbarConfig.header, titleId: viewLabelId }, toolbarProps))), + preact.createElement(ViewHarness, { liquid: viewVGrow, height: viewHeight, aspectRatio: viewAspectRatio, labeledById: viewLabelId }, + this.renderView(props), + this.buildAppendContent()), + toolbarConfig.footer && (preact.createElement(Toolbar, Object.assign({ ref: this.footerRef, extraClassName: "fc-footer-toolbar", model: toolbarConfig.footer, titleId: "" }, toolbarProps))))); + } + componentDidMount() { + let { props } = this; + this.calendarInteractions = props.pluginHooks.calendarInteractions + .map((CalendarInteractionClass) => new CalendarInteractionClass(props)); + window.addEventListener('resize', this.handleWindowResize); + let { propSetHandlers } = props.pluginHooks; + for (let propName in propSetHandlers) { + propSetHandlers[propName](props[propName], props); + } + } + componentDidUpdate(prevProps) { + let { props } = this; + let { propSetHandlers } = props.pluginHooks; + for (let propName in propSetHandlers) { + if (props[propName] !== prevProps[propName]) { + propSetHandlers[propName](props[propName], props); + } + } + } + componentWillUnmount() { + window.removeEventListener('resize', this.handleWindowResize); + this.resizeRunner.clear(); + for (let interaction of this.calendarInteractions) { + interaction.destroy(); + } + this.props.emitter.trigger('_unmount'); + } + buildAppendContent() { + let { props } = this; + let children = props.pluginHooks.viewContainerAppends.map((buildAppendContent) => buildAppendContent(props)); + return preact.createElement(preact.Fragment, {}, ...children); + } + renderView(props) { + let { pluginHooks } = props; + let { viewSpec } = props; + let viewProps = { + dateProfile: props.dateProfile, + businessHours: props.businessHours, + eventStore: props.renderableEventStore, + eventUiBases: props.eventUiBases, + dateSelection: props.dateSelection, + eventSelection: props.eventSelection, + eventDrag: props.eventDrag, + eventResize: props.eventResize, + isHeightAuto: props.isHeightAuto, + forPrint: props.forPrint, + }; + let transformers = this.buildViewPropTransformers(pluginHooks.viewPropsTransformers); + for (let transformer of transformers) { + Object.assign(viewProps, transformer.transform(viewProps, props)); + } + let ViewComponent = viewSpec.component; + return (preact.createElement(ViewComponent, Object.assign({}, viewProps))); + } +} +function buildToolbarProps(viewSpec, dateProfile, dateProfileGenerator, currentDate, now, title) { + // don't force any date-profiles to valid date profiles (the `false`) so that we can tell if it's invalid + let todayInfo = dateProfileGenerator.build(now, undefined, false); // TODO: need `undefined` or else INFINITE LOOP for some reason + let prevInfo = dateProfileGenerator.buildPrev(dateProfile, currentDate, false); + let nextInfo = dateProfileGenerator.buildNext(dateProfile, currentDate, false); + return { + title, + activeButton: viewSpec.type, + navUnit: viewSpec.singleUnit, + isTodayEnabled: todayInfo.isValid && !internalCommon.rangeContainsMarker(dateProfile.currentRange, now), + isPrevEnabled: prevInfo.isValid, + isNextEnabled: nextInfo.isValid, + }; +} +// Plugin +// ----------------------------------------------------------------------------------------------------------------- +function buildViewPropTransformers(theClasses) { + return theClasses.map((TheClass) => new TheClass()); +} + +class Calendar extends internalCommon.CalendarImpl { + constructor(el, optionOverrides = {}) { + super(); + this.isRendering = false; + this.isRendered = false; + this.currentClassNames = []; + this.customContentRenderId = 0; + this.handleAction = (action) => { + // actions we know we want to render immediately + switch (action.type) { + case 'SET_EVENT_DRAG': + case 'SET_EVENT_RESIZE': + this.renderRunner.tryDrain(); + } + }; + this.handleData = (data) => { + this.currentData = data; + this.renderRunner.request(data.calendarOptions.rerenderDelay); + }; + this.handleRenderRequest = () => { + if (this.isRendering) { + this.isRendered = true; + let { currentData } = this; + internalCommon.flushSync(() => { + preact.render(preact.createElement(internalCommon.CalendarRoot, { options: currentData.calendarOptions, theme: currentData.theme, emitter: currentData.emitter }, (classNames, height, isHeightAuto, forPrint) => { + this.setClassNames(classNames); + this.setHeight(height); + return (preact.createElement(internalCommon.RenderId.Provider, { value: this.customContentRenderId }, + preact.createElement(CalendarContent, Object.assign({ isHeightAuto: isHeightAuto, forPrint: forPrint }, currentData)))); + }), this.el); + }); + } + else if (this.isRendered) { + this.isRendered = false; + preact.render(null, this.el); + this.setClassNames([]); + this.setHeight(''); + } + }; + internalCommon.ensureElHasStyles(el); + this.el = el; + this.renderRunner = new internalCommon.DelayedRunner(this.handleRenderRequest); + new CalendarDataManager({ + optionOverrides, + calendarApi: this, + onAction: this.handleAction, + onData: this.handleData, + }); + } + render() { + let wasRendering = this.isRendering; + if (!wasRendering) { + this.isRendering = true; + } + else { + this.customContentRenderId += 1; + } + this.renderRunner.request(); + if (wasRendering) { + this.updateSize(); + } + } + destroy() { + if (this.isRendering) { + this.isRendering = false; + this.renderRunner.request(); + } + } + updateSize() { + internalCommon.flushSync(() => { + super.updateSize(); + }); + } + batchRendering(func) { + this.renderRunner.pause('batchRendering'); + func(); + this.renderRunner.resume('batchRendering'); + } + pauseRendering() { + this.renderRunner.pause('pauseRendering'); + } + resumeRendering() { + this.renderRunner.resume('pauseRendering', true); + } + resetOptions(optionOverrides, changedOptionNames) { + this.currentDataManager.resetOptions(optionOverrides, changedOptionNames); + } + setClassNames(classNames) { + if (!internalCommon.isArraysEqual(classNames, this.currentClassNames)) { + let { classList } = this.el; + for (let className of this.currentClassNames) { + classList.remove(className); + } + for (let className of classNames) { + classList.add(className); + } + this.currentClassNames = classNames; + } + } + setHeight(height) { + internalCommon.applyStyleProp(this.el, 'height', height); + } +} + +function formatDate(dateInput, options = {}) { + let dateEnv = buildDateEnv(options); + let formatter = internalCommon.createFormatter(options); + let dateMeta = dateEnv.createMarkerMeta(dateInput); + if (!dateMeta) { // TODO: warning? + return ''; + } + return dateEnv.format(dateMeta.marker, formatter, { + forcedTzo: dateMeta.forcedTzo, + }); +} +function formatRange(startInput, endInput, options) { + let dateEnv = buildDateEnv(typeof options === 'object' && options ? options : {}); // pass in if non-null object + let formatter = internalCommon.createFormatter(options); + let startMeta = dateEnv.createMarkerMeta(startInput); + let endMeta = dateEnv.createMarkerMeta(endInput); + if (!startMeta || !endMeta) { // TODO: warning? + return ''; + } + return dateEnv.formatRange(startMeta.marker, endMeta.marker, formatter, { + forcedStartTzo: startMeta.forcedTzo, + forcedEndTzo: endMeta.forcedTzo, + isEndExclusive: options.isEndExclusive, + defaultSeparator: internalCommon.BASE_OPTION_DEFAULTS.defaultRangeSeparator, + }); +} +// TODO: more DRY and optimized +function buildDateEnv(settings) { + let locale = buildLocale(settings.locale || 'en', organizeRawLocales([]).map); // TODO: don't hardcode 'en' everywhere + return new internalCommon.DateEnv(Object.assign(Object.assign({ timeZone: internalCommon.BASE_OPTION_DEFAULTS.timeZone, calendarSystem: 'gregory' }, settings), { locale })); +} + +// HELPERS +/* +if nextDayThreshold is specified, slicing is done in an all-day fashion. +you can get nextDayThreshold from context.nextDayThreshold +*/ +function sliceEvents(props, allDay) { + return internalCommon.sliceEventStore(props.eventStore, props.eventUiBases, props.dateProfile.activeRange, allDay ? props.nextDayThreshold : null).fg; +} + +const version = '6.1.15'; + +exports.JsonRequestError = internalCommon.JsonRequestError; +exports.Calendar = Calendar; +exports.createPlugin = createPlugin; +exports.formatDate = formatDate; +exports.formatRange = formatRange; +exports.globalLocales = globalLocales; +exports.globalPlugins = globalPlugins; +exports.sliceEvents = sliceEvents; +exports.version = version; + +},{"./internal-common.cjs":2,"preact":11,"preact/compat":10}],2:[function(require,module,exports){ +'use strict'; + +var preact = require('preact'); +var compat = require('preact/compat'); + +function _interopNamespace(e) { + if (e && e.__esModule) return e; + var n = Object.create(null); + if (e) { + Object.keys(e).forEach(function (k) { + if (k !== 'default') { + var d = Object.getOwnPropertyDescriptor(e, k); + Object.defineProperty(n, k, d.get ? d : { + enumerable: true, + get: function () { return e[k]; } + }); + } + }); + } + n["default"] = e; + return Object.freeze(n); +} + +var preact__namespace = /*#__PURE__*/_interopNamespace(preact); + +const styleTexts = []; +const styleEls = new Map(); +function injectStyles(styleText) { + styleTexts.push(styleText); + styleEls.forEach((styleEl) => { + appendStylesTo(styleEl, styleText); + }); +} +function ensureElHasStyles(el) { + if (el.isConnected && // sometimes true if SSR system simulates DOM + el.getRootNode // sometimes undefined if SSR system simulates DOM + ) { + registerStylesRoot(el.getRootNode()); + } +} +function registerStylesRoot(rootNode) { + let styleEl = styleEls.get(rootNode); + if (!styleEl || !styleEl.isConnected) { + styleEl = rootNode.querySelector('style[data-fullcalendar]'); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.setAttribute('data-fullcalendar', ''); + const nonce = getNonceValue(); + if (nonce) { + styleEl.nonce = nonce; + } + const parentEl = rootNode === document ? document.head : rootNode; + const insertBefore = rootNode === document + ? parentEl.querySelector('script,link[rel=stylesheet],link[as=style],style') + : parentEl.firstChild; + parentEl.insertBefore(styleEl, insertBefore); + } + styleEls.set(rootNode, styleEl); + hydrateStylesRoot(styleEl); + } +} +function hydrateStylesRoot(styleEl) { + for (const styleText of styleTexts) { + appendStylesTo(styleEl, styleText); + } +} +function appendStylesTo(styleEl, styleText) { + const { sheet } = styleEl; + const ruleCnt = sheet.cssRules.length; + styleText.split('}').forEach((styleStr, i) => { + styleStr = styleStr.trim(); + if (styleStr) { + sheet.insertRule(styleStr + '}', ruleCnt + i); + } + }); +} +// nonce +// ------------------------------------------------------------------------------------------------- +let queriedNonceValue; +function getNonceValue() { + if (queriedNonceValue === undefined) { + queriedNonceValue = queryNonceValue(); + } + return queriedNonceValue; +} +/* +TODO: discourage meta tag and instead put nonce attribute on placeholder