diff --git a/development/Dockerfile b/development/Dockerfile index 9ee1ea4ad..ac7ab0d0f 100644 --- a/development/Dockerfile +++ b/development/Dockerfile @@ -39,7 +39,8 @@ RUN python3 -m venv /root/venv && \ /root/venv/bin/pip install --no-cache-dir --upgrade \ pip \ setuptools \ - black + black \ + pyshacl==0.40.1 WORKDIR /workspace/development diff --git a/development/docs/adr/0025-bootstrap-ontology-capability.md b/development/docs/adr/0025-bootstrap-ontology-capability.md new file mode 100644 index 000000000..9ca3fbc30 --- /dev/null +++ b/development/docs/adr/0025-bootstrap-ontology-capability.md @@ -0,0 +1,95 @@ +# 25) Bootstrap ontology capability {#adr_0025} + +Date: 2026-09-09 + +## Status + +accepted + +## Context + +A newly installed Wikibase is empty. For people starting a Wikibase project, +choosing an initial ontology can be a difficult first step. A small, curated +foundation of properties gives them a practical starting point for modelling a +domain and configuring related tools without requiring them to discover and +create every common property manually. + +The community-maintained +[wikibase-bootstrap](https://gitlab.wikimedia.org/olea/wikibase-bootstrap/) +project provides reviewed OWL/Turtle source material based on established +Wikibase patterns. Its Python importer, however, assumes fixed local IDs. +Wikibase Suite must not assume that an upstream source IRI will become a +particular local `P…` identifier. + +## Decision + +Bundle a `WikibaseBootstrap` MediaWiki extension in the Wikibase image. This +puts two reviewed, property-only ontology starting points directly where a new +Wikibase administrator can use them, rather than requiring a separate Python +or command-line bootstrap workflow. The feature is opt-in and is available +only while Wikibase has no active properties. + +The extension Special page and the extension's regular MediaWiki maintenance +script use the same service, so the same bootstrap can be run from either the +administrative interface or the command line. The Special page is the intended +onboarding path; the maintenance script supports automated or operational use. + +The service creates properties through Wikibase's entity-store API and lets +Wikibase allocate fresh local IDs. Bundle source IRIs are stable planning keys; +claims between bundle properties are resolved using the IDs allocated in that +same run. Both interfaces use the same server-owned service, which rechecks +eligibility immediately before applying a bundle. + +The bundled Turtle retains upstream attribution, licence, provenance, and +version metadata. Its narrow profile is checked with pySHACL by the test suite +and CI. The running extension deliberately has no Python or RDF/SHACL runtime +dependency: it reads only the reviewed bundled profile. + +For small, exploratory Wikibases, the Special page can delete at most 25 +existing Wikibase properties after an explicit typed confirmation, but only +when no item statements use properties. This is a convenience reset, not a +general-purpose data-reset facility. + +## Consequences + +The capability preserves the supported choice to start with a Wikibase without +active properties, +while providing a more accessible onboarding path for users who want a +community-curated foundation. + +Application is not transactional. A request failure can leave a partially +created bundle, and the capability has no durable run record, retry flow, or +recovery tooling. The reset facility likewise has no maintenance lock or audit +record. These limits are acceptable only for the deliberately narrow, +property-bootstrap use case. + +The bundles include the standard Wikibase properties used to describe +external-identifier formatter and canonical RDF URI templates. Configuring +Wikibase to use those properties is deliberately not part of this capability. +A default P7/P8 configuration would improve the normal fresh-instance path, +but could point to unrelated or deleted properties on an instance whose +identifiers had previously been allocated. The feature therefore leaves that +optional configuration to Wikibase operators and its upstream documentation. A +later follow-up may provide a safer configuration workflow or Suite-specific +guidance. + +## Explicitly out of scope + +- Uploading arbitrary Turtle or OWL files. +- A general RDF/OWL importer, runtime SHACL validation, or network imports. +- Seed data, item creation, and bulk data import. +- Adding or merging bundle properties into a Wikibase with active properties. +- Persistent bootstrap history, source-IRI-to-local-ID run records, + transactional/recoverable import runs, and a general reset workflow. + +Those are possible future directions. If pursued, they require a real RDF +parser and SHACL engine, a server-owned import-run model, durable provenance, +and an explicit conflict/recovery policy rather than an extension of this +property-bootstrap path. + +## References + +- [wikibase-bootstrap community project](https://gitlab.wikimedia.org/olea/wikibase-bootstrap/) +- [T234943: bootstrap Wikibase installation with basic ontology](https://phabricator.wikimedia.org/T234943) +- [T391815: Wikibase bootstrap properties set](https://phabricator.wikimedia.org/T391815) +- [T430204: testable bootstrap ontology spike](https://phabricator.wikimedia.org/T430204) diff --git a/development/docs/adr/index.md b/development/docs/adr/index.md index ed6fdaaa2..480cc3826 100644 --- a/development/docs/adr/index.md +++ b/development/docs/adr/index.md @@ -34,3 +34,4 @@ Current ADRs include: - [22 - WBS Tools foundation](0022-wbs-tools-foundation.md) - [23 - release preflight checks](0023-release-preflight.md) - [24 - WBS upgrade command](0024-wbs-upgrade-command.md) +- [25 - bootstrap ontology capability](0025-bootstrap-ontology-capability.md) diff --git a/development/eslint.config.mjs b/development/eslint.config.mjs index 7cd3e880b..b4bc92bc0 100644 --- a/development/eslint.config.mjs +++ b/development/eslint.config.mjs @@ -79,6 +79,18 @@ export default defineConfigWithVueTs( globals: globals.browser } }, + { + // MediaWiki ResourceLoader packages extension Vue modules as CommonJS. + name: 'wikibase-bootstrap/resource-loader', + files: [ 'images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/**/*.{js,vue}' ], + languageOptions: { + globals: { ...globals.browser, require: 'readonly' } + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + 'vue/block-lang': 'off' + } + }, { ...mocha.configs.recommended, name: 'wbs/mocha', diff --git a/development/images/wikibase/README.md b/development/images/wikibase/README.md index b7ea848cf..b4c0e0156 100644 --- a/development/images/wikibase/README.md +++ b/development/images/wikibase/README.md @@ -95,6 +95,19 @@ using the normal MediaWiki instructions for that extension. A complete externally managed configuration continues to control its own extension loading. +### Bootstrap an initial ontology + +To get started with a Wikibase without active properties, you can bootstrap an +initial set of properties. These community-curated bundles include properties +mapped to Wikidata and common RDF and OWL vocabularies, providing a shared +starting point for interoperable data. + +To apply an initial ontology, visit +`https:///wiki/Special:OntologyBootstrap` to see the available options. + +To configure links from external identifiers created with these properties, see +[Wikibase's configuration guide for external identifiers](https://www.mediawiki.org/wiki/Wikibase/Installation/Advanced_configuration#Define_links_for_external_identifiers). + ### Login with Wikimedia To enable Wikimedia login: diff --git a/development/images/wikibase/build/extensions.json b/development/images/wikibase/build/extensions.json index c05158ff2..8cab05846 100644 --- a/development/images/wikibase/build/extensions.json +++ b/development/images/wikibase/build/extensions.json @@ -132,6 +132,12 @@ "source": { "path": "runtime/var/www/html/extensions/WikibaseSuite" } + }, + { + "name": "WikibaseBootstrap", + "source": { + "path": "runtime/var/www/html/extensions/WikibaseBootstrap" + } } ] } diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/README.md b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/README.md new file mode 100644 index 000000000..d104c6ff6 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/README.md @@ -0,0 +1,30 @@ +# Bundled bootstrap ontologies + +These files are reviewed source assets for the `WikibaseBootstrap` extension +at `/var/www/html/extensions/WikibaseBootstrap/ontologies/`; no runtime network +request is made to obtain them. + +Source: [`olea/wikibase-bootstrap`](https://gitlab.wikimedia.org/olea/wikibase-bootstrap/), +commit `40892e31fd812c9b1402506d1ee86cfc215e1dd6` (2026-04-08). + +The source ontology data declares CC0 1.0 Universal. The Suite profile +preserves the source property and ontology metadata while replacing fixed local +IDs with stable source IRIs; the server-owned importer then allocates the local +`P…` IDs. Explicit `wbb:claim` entries encode the Wikibase statements that the +upstream converter derives from its `owl:equivalentProperty` mappings. + +The minimal and extended variants remain separate user-visible choices. + +The same server-owned operation is available without the Special page: + +```sh +php extensions/WikibaseBootstrap/maintenance/BootstrapOntology.php \ + --bundle extended --user Admin --confirm +``` + +Run this command from the MediaWiki installation directory. It only applies to +an eligible Wikibase without active properties, just like the Special page. + +The feature-local validation assets and pySHACL runner live in +`development/tests/wikibase-bootstrap/`. They are development and CI +dependencies, not runtime dependencies of the extension. diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/extension.json b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/extension.json new file mode 100644 index 000000000..6372dd7df --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/extension.json @@ -0,0 +1,61 @@ +{ + "name": "WikibaseBootstrap", + "version": "0.1.0", + "author": ["Wikibase Suite Team"], + "url": "https://www.mediawiki.org/wiki/Wikibase/Suite", + "namemsg": "wikibasebootstrap-extensionname", + "descriptionmsg": "wikibasebootstrap-desc", + "type": "specialpage", + "license-name": "BSD-3-Clause", + "AutoloadNamespaces": { + "MediaWiki\\Extension\\WikibaseBootstrap\\": "includes/" + }, + "ResourceModules": { + "ext.wikibaseBootstrap.apply": { + "class": "MediaWiki\\ResourceLoader\\CodexModule", + "localBasePath": "", + "packageFiles": [ + "resources/ext.wikibaseBootstrap/init.js", + "resources/ext.wikibaseBootstrap/OntologyBootstrapApp.vue" + ], + "messages": [ + "ontologybootstrap-completion-created", + "ontologybootstrap-completion-description", + "ontologybootstrap-reset-completed", + "ontologybootstrap-reset-completion-description", + "ontologybootstrap-bundle-catalog-heading", + "ontologybootstrap-bundle-derived-from", + "ontologybootstrap-bundle-source-label", + "ontologybootstrap-bundle-property-count", + "ontologybootstrap-bundle-item-class-count", + "ontologybootstrap-metadata-version", + "ontologybootstrap-metadata-license", + "ontologybootstrap-metadata-publisher", + "ontologybootstrap-metadata-contributors", + "ontologybootstrap-unavailable", + "ontologybootstrap-apply", + "ontologybootstrap-apply-dialog-title", + "ontologybootstrap-cancel", + "ontologybootstrap-apply-confirmation" + ], + "styles": ["resources/ext.wikibaseBootstrap.css"], + "codexComponents": ["CdxButton", "CdxDialog", "CdxMessage", "CdxTextInput"], + "dependencies": ["codex-styles"] + } + }, + "SpecialPages": { + "OntologyBootstrap": "MediaWiki\\Extension\\WikibaseBootstrap\\SpecialOntologyBootstrap" + }, + "AvailableRights": ["bootstrapontology", "resetwikibaseentities"], + "GroupPermissions": { + "sysop": { "bootstrapontology": true, "resetwikibaseentities": true } + }, + "MessagesDirs": { "WikibaseBootstrap": ["i18n"] }, + "config": { + "WikibaseBootstrapBundleDirectory": { + "value": "/var/www/html/extensions/WikibaseBootstrap/ontologies", + "public": false + } + }, + "manifest_version": 2 +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/i18n/en.json b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/i18n/en.json new file mode 100644 index 000000000..d559c93a5 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/i18n/en.json @@ -0,0 +1,42 @@ +{ + "@metadata": { "authors": ["Wikibase Suite Team"] }, + "wikibasebootstrap-extensionname": "Wikibase Bootstrap", + "wikibasebootstrap-desc": "Validates and applies bootstrap ontology bundles to Wikibase instances without active properties", + "ontologybootstrap": "Create initial ontology", + "ontologybootstrap-summary": "Set up a Wikibase with a small, reviewed foundation of properties. Choose a bundle below to create its initial ontology. A bundle can only be applied when Wikibase has no active properties, and nothing changes until you explicitly confirm it.", + "ontologybootstrap-invalid-token": "Your session token was invalid. Please try again.", + "ontologybootstrap-not-empty": "This Wikibase has active properties and cannot be bootstrapped. Reset its properties first; bootstrap bundles are never merged into existing property data.", + "ontologybootstrap-existing-summary": "You can only bootstrap a Wikibase without active properties.", + "ontologybootstrap-reset-invitation": "It looks like your Wikibase is still relatively small with only $1 {{PLURAL:$1|property|properties}}. If you'd like to start over with one of the bootstrapped ontologies below, you need to first delete all existing Wikibase properties.", + "ontologybootstrap-existing-warning": "If you'd like to delete all of your current properties, you can do so below. Items, wiki pages, user accounts, and deletion history will not be deleted.", + "ontologybootstrap-complete": "Bootstrap applied successfully: $1 properties were created.", + "ontologybootstrap-reset-permission": "You do not have permission to reset Wikibase properties.", + "ontologybootstrap-reset-confirm-intro": "To delete all of your existing Wikibase $1 properties, type:", + "ontologybootstrap-reset-confirm-text": "DELETE $1 PROPERTIES", + "ontologybootstrap-reset-confirm-required": "Type DELETE $1 PROPERTIES exactly before deleting this batch.", + "ontologybootstrap-reset-submit": "Delete active Wikibase properties", + "ontologybootstrap-reset-progress": "Deleted up to $1 properties. $2 active properties remain; repeat the confirmed reset if needed.", + "ontologybootstrap-reset-too-many": "Ontology bootstrapping is only available for a Wikibase without active properties. To protect this existing Wikibase, its properties cannot be reset here once it contains more than $1 properties.", + "ontologybootstrap-reset-item-statements": "These properties cannot be reset here because existing items have statements that use Wikibase properties.", + "ontologybootstrap-ready-to-apply": "This Wikibase has no active properties and is eligible. Apply $1 properties from the selected bundle.", + "ontologybootstrap-apply": "Apply bundled ontology", + "ontologybootstrap-completion-created": "Initial ontology created.", + "ontologybootstrap-completion-description": "$1 {{PLURAL:$1|property was|properties were}} created. Bootstrap setup is complete.", + "ontologybootstrap-reset-completed": "Wikibase properties deleted.", + "ontologybootstrap-reset-completion-description": "$1 {{PLURAL:$1|property was|properties were}} deleted.", + "ontologybootstrap-bundle-catalog-heading": "Set of properties", + "ontologybootstrap-bundle-derived-from": "Derived from the community-curated", + "ontologybootstrap-bundle-source-label": "Wikibase bootstrap set", + "ontologybootstrap-bundle-property-count": "$1 {{PLURAL:$1|property|properties}}", + "ontologybootstrap-bundle-item-class-count": "$1 {{PLURAL:$1|item/class|items/classes}}", + "ontologybootstrap-metadata-version": "Version", + "ontologybootstrap-metadata-license": "License", + "ontologybootstrap-metadata-publisher": "Publisher", + "ontologybootstrap-metadata-contributors": "Contributors", + "ontologybootstrap-unavailable": "Unavailable", + "ontologybootstrap-apply-dialog-title": "Apply $1", + "ontologybootstrap-cancel": "Cancel", + "ontologybootstrap-apply-confirmation": "This will create $1 {{PLURAL:$1|property|properties}} in a Wikibase without active properties. It cannot be applied again without first deleting all active Wikibase properties.", + "ontologybootstrap-bundled-minimal": "Minimal set of properties", + "ontologybootstrap-bundled-extended": "Extended set of properties" +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/BootstrapService.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/BootstrapService.php new file mode 100644 index 000000000..b0fddcdb2 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/BootstrapService.php @@ -0,0 +1,220 @@ + */ + private array $bundlePaths; + + /** @param array $bundlePaths */ + public function __construct( array $bundlePaths ) { + $this->bundlePaths = $bundlePaths; + } + + public static function newFromConfig(): self { + $directory = rtrim( + (string)MediaWikiServices::getInstance() + ->getMainConfig() + ->get( 'WikibaseBootstrapBundleDirectory' ), + '/' + ); + return new self( [ + 'minimal' => "$directory/wikibase-bootstrap.ttl", + 'extended' => "$directory/wikibase-bootstrap-extended.ttl", + ] ); + } + + /** @return array{properties: list>, errors: string[]} */ + public function planBundle( string $bundle ): array { + if ( !isset( $this->bundlePaths[$bundle] ) ) { + return [ 'properties' => [], 'errors' => [ 'Unknown bootstrap bundle.' ] ]; + } + $contents = file_get_contents( $this->bundlePaths[$bundle] ); + if ( $contents === false ) { + return [ 'properties' => [], 'errors' => [ 'The bootstrap bundle could not be read.' ] ]; + } + return ( new BundlePlanner() )->plan( $contents ); + } + + /** + * @return array{propertyCount: int, sourceIds: array} + */ + public function applyBundle( string $bundle, User $user ): array { + if ( !$user->isAllowed( 'bootstrapontology' ) ) { + throw new RuntimeException( 'You do not have permission to bootstrap this Wikibase.' ); + } + if ( !$this->isEligible() ) { + throw new RuntimeException( 'This Wikibase has active properties and cannot be bootstrapped.' ); + } + + $plan = $this->planBundle( $bundle ); + if ( $plan['errors'] !== [] ) { + throw new RuntimeException( implode( ' ', $plan['errors'] ) ); + } + + $revisions = []; + $sourceIds = []; + $guidGenerator = new GuidGenerator(); + $store = WikibaseRepo::getEntityStore(); + foreach ( $plan['properties'] as $propertyPlan ) { + $property = new Property( + null, + $this->fingerprint( $propertyPlan['label'], $propertyPlan['description'] ), + $propertyPlan['datatype'] + ); + $store->assignFreshId( $property ); + $revision = $store->saveEntity( + $property, + 'Ontology bootstrap', + $user + ); + $entity = $revision->getEntity(); + $id = $entity->getId(); + if ( !( $id instanceof PropertyId ) ) { + throw new RuntimeException( 'Wikibase did not assign a property ID during bootstrap.' ); + } + $sourceIds[$propertyPlan['sourceIri']] = $id->getSerialization(); + $revisions[$propertyPlan['sourceIri']] = $revision; + } + + foreach ( $plan['properties'] as $propertyPlan ) { + $revision = $revisions[$propertyPlan['sourceIri']]; + $property = $revision->getEntity(); + foreach ( $propertyPlan['claims'] as $claim ) { + if ( !isset( $sourceIds[$claim['property']] ) ) { + throw new RuntimeException( 'A bootstrap claim refers to an unknown bootstrap property.' ); + } + $property->getStatements()->addStatement( + new Statement( + new PropertyValueSnak( + new NumericPropertyId( $sourceIds[$claim['property']] ), + new StringValue( $claim['value'] ) + ), + null, + null, + $guidGenerator->newGuid( $property->getId() ) + ) + ); + } + if ( $propertyPlan['claims'] !== [] ) { + $revisions[$propertyPlan['sourceIri']] = $store->saveEntity( + $property, + 'Ontology bootstrap', + $user, + 0, + $revision->getRevisionId() + ); + } + } + + return [ + 'propertyCount' => count( $plan['properties'] ), + 'sourceIds' => $sourceIds, + ]; + } + + public function isEligible(): bool { + return $this->activePropertyCount() === 0; + } + + /** + * Determines whether the user has a bootstrap action available: applying a + * bundle or safely resetting a small, unused property set. + */ + public function isAvailableTo( User $user ): bool { + if ( !$user->isAllowed( 'bootstrapontology' ) ) { + return false; + } + + $resetState = $this->resetState(); + return $resetState['propertyCount'] === 0 || ( + $resetState['canOfferReset'] && + $user->isAllowed( 'resetwikibaseentities' ) + ); + } + + /** @return array{propertyCount: int, hasActiveItemStatements: bool, canOfferReset: bool} */ + public function resetState(): array { + $propertyCount = $this->activePropertyCount(); + $hasActiveItemStatements = $propertyCount > 0 && + $propertyCount <= self::RESET_PROPERTY_LIMIT && + $this->hasActiveItemStatements(); + + return [ + 'propertyCount' => $propertyCount, + 'hasActiveItemStatements' => $hasActiveItemStatements, + 'canOfferReset' => $propertyCount > 0 && + $propertyCount <= self::RESET_PROPERTY_LIMIT && + !$hasActiveItemStatements, + ]; + } + + public function activePropertyCount(): int { + $namespaces = WikibaseRepo::getEntityNamespaceLookup()->getEntityNamespaces(); + return (int)MediaWikiServices::getInstance() + ->getConnectionProvider() + ->getPrimaryDatabase() + ->newSelectQueryBuilder() + ->select( 'COUNT(*)' ) + ->from( 'page' ) + ->where( [ 'page_namespace' => $namespaces['property'] ] ) + ->caller( __METHOD__ ) + ->fetchField(); + } + + public function hasActiveItemStatements(): bool { + $namespaces = WikibaseRepo::getEntityNamespaceLookup()->getEntityNamespaces(); + $database = MediaWikiServices::getInstance() + ->getConnectionProvider() + ->getPrimaryDatabase(); + return $database + ->newSelectQueryBuilder() + ->select( '1' ) + ->from( 'page' ) + ->join( 'page_props', null, 'pp_page = page_id' ) + ->where( [ + 'page_namespace' => $namespaces['item'], + 'pp_propname' => 'wb-claims', + ] ) + ->andWhere( $database->expr( 'pp_value', '>', 0 ) ) + ->limit( 1 ) + ->caller( __METHOD__ ) + ->fetchField() !== false; + } + + /** @param array{value: string, language: string} $label */ + /** @param array{value: string, language: string}|null $description */ + private function fingerprint( array $label, ?array $description ): Fingerprint { + $labels = new TermList( [ new Term( $label['language'], $label['value'] ) ] ); + $descriptions = $description === null ? + new TermList() : + new TermList( [ new Term( $description['language'], $description['value'] ) ] ); + return new Fingerprint( $labels, $descriptions ); + } +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/BundlePlanner.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/BundlePlanner.php new file mode 100644 index 000000000..2252cd540 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/BundlePlanner.php @@ -0,0 +1,130 @@ +>, errors: string[]} */ + public function plan( string $contents ): array { + $prefixes = $this->prefixes( $contents ); + $properties = []; + $errors = []; + preg_match_all( + '/^([A-Za-z][A-Za-z0-9_-]*):([A-Za-z][A-Za-z0-9_-]*)\s+a\s+owl:(ObjectProperty|DatatypeProperty)\s*;(.*?)(?=^[A-Za-z][A-Za-z0-9_-]*:[A-Za-z][A-Za-z0-9_-]*\s+a\s+owl:|\z)/ms', + $contents, + $matches, + PREG_SET_ORDER + ); + + foreach ( $matches as $match ) { + $body = $match[4]; + $sourceIri = $this->prefixedTerm( $match[1] . ':' . $match[2], $prefixes ); + $label = $this->literal( $body, 'rdfs:label' ); + $datatype = $this->literal( $body, 'wbb:datatype' ); + if ( $sourceIri === null || $label === null || $datatype === null ) { + $errors[] = "The bootstrap entity {$match[1]}:{$match[2]} is missing a source IRI, label, or datatype."; + continue; + } + if ( !preg_match( '/wbb:kind\s+wbb:Property\b/', $body ) ) { + $errors[] = "The bootstrap entity {$match[1]}:{$match[2]} is not declared as a property."; + continue; + } + + $claims = $this->claims( $body, $prefixes ); + if ( $claims === null ) { + $errors[] = "The bootstrap entity {$match[1]}:{$match[2]} has an invalid claim."; + continue; + } + $properties[] = [ + 'sourceIri' => $sourceIri, + 'label' => $label, + 'description' => $this->literal( $body, 'dcterms:description' ), + 'datatype' => $datatype['value'], + 'claims' => $claims, + ]; + } + + if ( $properties === [] && $errors === [] ) { + $errors[] = 'No importable Suite bootstrap properties were found.'; + } + + return [ 'properties' => $properties, 'errors' => $errors ]; + } + + /** @return array */ + private function prefixes( string $contents ): array { + preg_match_all( + '/^@prefix\s+([A-Za-z][A-Za-z0-9_-]*):\s*<([^>]+)>\s*\./m', + $contents, + $matches, + PREG_SET_ORDER + ); + $prefixes = []; + foreach ( $matches as $match ) { + $prefixes[$match[1]] = $match[2]; + } + return $prefixes; + } + + /** @return array{value: string, language: string}|null */ + private function literal( string $body, string $predicate ): ?array { + if ( !preg_match( + '/' . preg_quote( $predicate, '/' ) . '\s+"((?:[^"\\\\]|\\\\.)*)"(?:@([A-Za-z-]+))?/', + $body, + $match + ) ) { + return null; + } + return [ + 'value' => stripcslashes( $match[1] ), + 'language' => ( $match[2] ?? '' ) ?: 'en', + ]; + } + + /** + * @param array $prefixes + * @return list + */ + private function claims( string $body, array $prefixes ): ?array { + preg_match_all( + '/wbb:claim\s+\[\s*wbb:property\s+([^;\s]+)\s*;\s*wbb:value\s+([^;\]]+)\s*;?\s*\]/ms', + $body, + $matches, + PREG_SET_ORDER + ); + $claims = []; + foreach ( $matches as $match ) { + $property = $this->term( trim( $match[1] ), $prefixes ); + $value = $this->term( trim( $match[2] ), $prefixes ); + if ( $property === null || $value === null ) { + return null; + } + $claims[] = [ 'property' => $property, 'value' => $value ]; + } + return $claims; + } + + /** @param array $prefixes */ + private function term( string $term, array $prefixes ): ?string { + if ( preg_match( '/^<([^>]+)>$/', $term, $match ) ) { + return $match[1]; + } + if ( preg_match( '/^"((?:[^"\\\\]|\\\\.)*)"$/', $term, $match ) ) { + return stripcslashes( $match[1] ); + } + return $this->prefixedTerm( $term, $prefixes ); + } + + /** @param array $prefixes */ + private function prefixedTerm( string $term, array $prefixes ): ?string { + if ( !preg_match( '/^([A-Za-z][A-Za-z0-9_-]*):([A-Za-z][A-Za-z0-9_-]*)$/', $term, $match ) ) { + return null; + } + return isset( $prefixes[$match[1]] ) ? $prefixes[$match[1]] . $match[2] : null; + } +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/SpecialOntologyBootstrap.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/SpecialOntologyBootstrap.php new file mode 100644 index 000000000..9caf7712e --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/includes/SpecialOntologyBootstrap.php @@ -0,0 +1,293 @@ +setHeaders(); + $this->checkPermissions(); + + $out = $this->getOutput(); + $out->addModuleStyles( [ 'codex-styles', 'ext.wikibaseBootstrap.apply' ] ); + $out->addModules( [ 'ext.wikibaseBootstrap.apply' ] ); + + $service = $this->bootstrapService(); + $resetState = $service->resetState(); + $propertyCount = $resetState['propertyCount']; + $applyError = ''; + $applyBundle = ''; + $resetError = ''; + $resetProgress = ''; + $request = $this->getRequest(); + $completion = $request->getSession()->get( self::FLASH_SESSION_KEY, [] ); + $request->getSession()->remove( self::FLASH_SESSION_KEY ); + + if ( $request->wasPosted() && $request->getVal( 'action' ) === 'apply' ) { + $applyBundle = (string)$request->getVal( 'bundle' ); + if ( !$this->getUser()->matchEditToken( $request->getVal( 'token' ) ) ) { + $applyError = $this->msg( 'ontologybootstrap-invalid-token' )->text(); + } else { + try { + $result = $service->applyBundle( $applyBundle, $this->getUser() ); + $request->getSession()->set( self::FLASH_SESSION_KEY, [ + 'completed' => $result['propertyCount'], + ] ); + $out->redirect( $this->getPageTitle()->getLocalURL() ); + return; + } catch ( RuntimeException $exception ) { + $applyError = $exception->getMessage(); + } + } + } + + if ( $propertyCount > 0 && + $request->wasPosted() && + $request->getVal( 'action' ) === 'reset' + ) { + $resetState = $service->resetState(); + $propertyCount = $resetState['propertyCount']; + if ( !$this->getUser()->matchEditToken( $request->getVal( 'token' ) ) ) { + $resetError = $this->msg( 'ontologybootstrap-invalid-token' )->text(); + } elseif ( !$this->getUser()->isAllowed( 'resetwikibaseentities' ) ) { + $resetError = $this->msg( 'ontologybootstrap-reset-permission' )->text(); + } elseif ( $propertyCount > BootstrapService::RESET_PROPERTY_LIMIT ) { + $resetError = $this->msg( + 'ontologybootstrap-reset-too-many', + BootstrapService::RESET_PROPERTY_LIMIT + )->text(); + } elseif ( $resetState['hasActiveItemStatements'] ) { + $resetError = $this->msg( 'ontologybootstrap-reset-item-statements' )->text(); + } elseif ( $request->getVal( 'confirmation' ) !== "DELETE $propertyCount PROPERTIES" ) { + $resetError = $this->msg( 'ontologybootstrap-reset-confirm-required', $propertyCount )->text(); + } else { + $deletedPropertyCount = $propertyCount; + $this->deletePropertyBatch(); + $resetState = $service->resetState(); + $propertyCount = $resetState['propertyCount']; + if ( $propertyCount === 0 ) { + $request->getSession()->set( self::FLASH_SESSION_KEY, [ + 'resetCompleted' => $deletedPropertyCount, + ] ); + $out->redirect( $this->getPageTitle()->getLocalURL() ); + return; + } + $resetProgress = $this->msg( + 'ontologybootstrap-reset-progress', + min( BootstrapService::RESET_PROPERTY_LIMIT, $deletedPropertyCount ), + $propertyCount + )->text(); + } + } + + $out->addJsConfigVars( 'wgWikibaseBootstrap', [ + 'summary' => $this->msg( 'ontologybootstrap-summary' )->text(), + 'bundles' => $this->bundleCatalog( $service, $propertyCount, $applyBundle, $applyError ), + 'completed' => (int)( $completion['completed'] ?? 0 ), + 'resetCompleted' => (int)( $completion['resetCompleted'] ?? 0 ), + 'pageUrl' => $this->getPageTitle()->getLocalURL(), + 'token' => $this->getUser()->getEditToken(), + 'reset' => $propertyCount > 0 ? $this->resetData( + $propertyCount, + $resetState['hasActiveItemStatements'], + $resetState['canOfferReset'], + $resetError, + $resetProgress + ) : null, + ] ); + $out->addHTML( Html::rawElement( 'div', [ 'id' => 'wikibase-bootstrap-app' ] ) ); + } + + /** @return list> */ + private function bundleCatalog( + BootstrapService $service, + int $propertyCount, + string $applyBundle, + string $applyError + ): array { + $bundles = []; + foreach ( $this->bundles() as $key => $bundle ) { + $contents = file_get_contents( $bundle['path'] ) ?: ''; + $plan = $service->planBundle( $key ); + $metadata = $this->ontologyMetadata( $contents ); + $bundles[] = [ + 'key' => $key, + 'title' => $this->msg( $bundle['message'] )->text(), + 'description' => $metadata['description'], + 'contributors' => $metadata['contributors'], + 'license' => $metadata['license'], + 'publisher' => $metadata['publisher'], + 'publisherLabel' => $metadata['publisherLabel'], + 'rights' => $metadata['rights'], + 'version' => $metadata['version'], + 'sourceUrl' => $metadata['source'], + 'propertyCount' => count( $plan['properties'] ), + 'itemCount' => 0, + 'disabled' => $propertyCount > 0, + 'error' => $plan['errors'] === [] && $key === $applyBundle ? + $applyError : implode( ' ', $plan['errors'] ), + ]; + } + return $bundles; + } + + /** + * Extract display metadata from the final ontology declaration in a reviewed bundle. + * + * This deliberately remains a narrow reader for the bundled profile. It is not a + * general Turtle parser or an upload validator. + * + * @return array{description: string, contributors: list, license: string, publisher: string, publisherLabel: string, rights: string, source: string, version: string} + */ + private function ontologyMetadata( string $contents ): array { + $metadata = ''; + preg_match( + '/wbbs:bundle\s+a\s+owl:Ontology\s*;(?.*?)\s*\.\s*$/s', + $contents, + $match + ); + $metadata = $match['metadata'] ?? ''; + $literal = static function ( string $predicate ) use ( $metadata ): string { + preg_match( + '/' . preg_quote( $predicate, '/' ) . '\\s+"([^"]+)"(?:@[A-Za-z-]+)?\\s*(?:;|\\.|$)/', + $metadata, + $match + ); + return $match[1] ?? ''; + }; + $iri = static function ( string $predicate ) use ( $metadata ): string { + preg_match( + '/' . preg_quote( $predicate, '/' ) . '\\s+<([^>]+)>\\s*;/', + $metadata, + $match + ); + return $match[1] ?? ''; + }; + $contributors = []; + if ( preg_match( '/dcterms:contributor\\s+(.*?)\\s*;/s', $metadata, $match ) ) { + preg_match_all( '/"([^"]+)"/', $match[1], $contributorMatches ); + $contributors = $contributorMatches[1]; + } + + $publisher = $iri( 'dcterms:publisher' ); + $publisherLabel = ''; + if ( $publisher !== '' ) { + preg_match( + '/' . preg_quote( '<' . $publisher . '>', '/' ) . '\\s+rdfs:label\\s+"([^"]+)"/', + $contents, + $match + ); + $publisherLabel = $match[1] ?? ''; + } + + return [ + 'description' => $literal( 'dcterms:description' ) ?: 'Reviewed ontology bundle.', + 'contributors' => $contributors, + 'license' => $iri( 'dcterms:license' ), + 'publisher' => $publisher, + 'publisherLabel' => $publisherLabel, + 'rights' => $literal( 'dcterms:rights' ), + 'source' => $iri( 'dcterms:source' ), + 'version' => $literal( 'owl:versionInfo' ), + ]; + } + + /** @return array */ + private function resetData( + int $propertyCount, + bool $hasActiveItemStatements, + bool $canOfferReset, + string $error, + string $progress + ): array { + return [ + 'summary' => $this->msg( 'ontologybootstrap-existing-summary' )->text(), + 'resetInvitation' => $this->msg( + 'ontologybootstrap-reset-invitation', + $propertyCount + )->text(), + 'warning' => $this->msg( 'ontologybootstrap-existing-warning' )->text(), + 'hasActiveItemStatements' => $hasActiveItemStatements, + 'itemStatementsError' => $this->msg( 'ontologybootstrap-reset-item-statements' )->text(), + 'canOfferReset' => $canOfferReset, + 'canReset' => $canOfferReset && + $this->getUser()->isAllowed( 'resetwikibaseentities' ), + 'tooMany' => $this->msg( + 'ontologybootstrap-reset-too-many', + BootstrapService::RESET_PROPERTY_LIMIT + )->text(), + 'permissionError' => $this->msg( 'ontologybootstrap-reset-permission' )->text(), + 'confirmationIntro' => $this->msg( 'ontologybootstrap-reset-confirm-intro', $propertyCount )->text(), + 'confirmationText' => $this->msg( 'ontologybootstrap-reset-confirm-text', $propertyCount )->text(), + 'submitLabel' => $this->msg( 'ontologybootstrap-reset-submit' )->text(), + 'pageUrl' => $this->getPageTitle()->getLocalURL(), + 'token' => $this->getUser()->getEditToken(), + 'error' => $error, + 'progress' => $progress, + ]; + } + + private function deletePropertyBatch(): void { + $namespaces = WikibaseRepo::getEntityNamespaceLookup()->getEntityNamespaces(); + $rows = MediaWikiServices::getInstance() + ->getConnectionProvider() + ->getPrimaryDatabase() + ->newSelectQueryBuilder() + ->select( [ 'page_namespace', 'page_title' ] ) + ->from( 'page' ) + ->where( [ 'page_namespace' => $namespaces['property'] ] ) + ->orderBy( 'page_id' ) + ->limit( BootstrapService::RESET_PROPERTY_LIMIT ) + ->caller( __METHOD__ ) + ->fetchResultSet(); + $store = WikibaseRepo::getEntityStore(); + $lookup = WikibaseRepo::getEntityIdLookup(); + foreach ( $rows as $row ) { + $title = Title::makeTitle( (int)$row->page_namespace, $row->page_title ); + $id = $lookup->getEntityIdForTitle( $title ); + if ( $id ) { + $store->deleteEntity( $id, 'Ontology bootstrap property reset', $this->getUser() ); + } + } + } + + private function bootstrapService(): BootstrapService { + return BootstrapService::newFromConfig(); + } + + /** @return array */ + private function bundles(): array { + $directory = rtrim( + (string)$this->getConfig()->get( 'WikibaseBootstrapBundleDirectory' ), + '/' + ); + return [ + 'minimal' => [ + 'message' => 'ontologybootstrap-bundled-minimal', + 'path' => "$directory/wikibase-bootstrap.ttl", + ], + 'extended' => [ + 'message' => 'ontologybootstrap-bundled-extended', + 'path' => "$directory/wikibase-bootstrap-extended.ttl", + ], + ]; + } +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/maintenance/BootstrapOntology.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/maintenance/BootstrapOntology.php new file mode 100644 index 000000000..ec52e1e8e --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/maintenance/BootstrapOntology.php @@ -0,0 +1,50 @@ +addDescription( 'Apply a reviewed Wikibase Suite ontology bootstrap to a Wikibase without active properties.' ); + $this->addOption( 'bundle', 'Bundle to apply: minimal or extended.', true, true ); + $this->addOption( 'user', 'Existing user to attribute the edits to.', true, true ); + $this->addOption( 'confirm', 'Required acknowledgement that this writes Wikibase properties.' ); + $this->requireExtension( 'WikibaseBootstrap' ); + } + + public function execute(): void { + if ( !$this->hasOption( 'confirm' ) ) { + $this->fatalError( 'Pass --confirm to apply a bootstrap bundle.', 1 ); + } + $user = MediaWikiServices::getInstance() + ->getUserFactory() + ->newFromName( $this->getOption( 'user' ) ); + if ( !$user || !$user->isRegistered() ) { + $this->fatalError( 'The --user value must name an existing registered user.', 1 ); + } + + try { + $result = BootstrapService::newFromConfig()->applyBundle( + $this->getOption( 'bundle' ), + $user + ); + } catch ( RuntimeException $exception ) { + $this->fatalError( $exception->getMessage(), 1 ); + } + $this->output( "Applied {$result['propertyCount']} properties from the {$this->getOption( 'bundle' )} bundle.\n" ); + } +} + +$maintClass = BootstrapOntology::class; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies/wikibase-bootstrap-extended.ttl b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies/wikibase-bootstrap-extended.ttl new file mode 100644 index 000000000..d2aee7168 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies/wikibase-bootstrap-extended.ttl @@ -0,0 +1,133 @@ +@prefix dcterms: . +@prefix foaf: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix schema: . +@prefix skos: . +@prefix vann: . +@prefix wbb: . +@prefix wbbs: . + +wbbs:instance-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "instance of"@en ; + dcterms:description "rdf:type"@en ; + owl:equivalentProperty rdf:type ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P31" ] . + +wbbs:subclass-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "subclass of"@en ; + dcterms:description "rdfs:subClassOf"@en ; + owl:equivalentProperty rdfs:subClassOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P279" ] . + +wbbs:part-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "part of"@en ; + dcterms:description "schema:isPartOf"@en ; + owl:equivalentProperty schema:isPartOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P361" ] . + +wbbs:subproperty-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-property" ; + rdfs:label "subproperty of"@en ; + dcterms:description "rdfs:subPropertyOf"@en ; + owl:equivalentProperty rdfs:subPropertyOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1647" ] . + +wbbs:exact-match a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "url" ; + rdfs:label "exact match"@en ; + dcterms:description "skos:exactMatch"@en ; + owl:equivalentProperty skos:exactMatch ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P2888" ] . + +wbbs:wikidata-entity-id a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "external-id" ; + rdfs:label "Wikidata Entity ID"@en ; + dcterms:description "used to indicate a mapping (of some undefined nature, but can be further specified by individual Wikibases) from a Wikibase Item, Property, Lexeme, etc. to Wikidata"@en ; + wbb:claim [ wbb:property wbbs:formatter-uri ; wbb:value "http://www.wikidata.org/entity/$1" ] ; + wbb:claim [ wbb:property wbbs:formatter-uri-for-rdf ; wbb:value "http://www.wikidata.org/entity/$1" ] . + +wbbs:formatter-uri a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "string" ; + rdfs:label "formatter URI"@en ; + dcterms:description "web page URI; URI template from which \"$1\" can be automatically replaced with the effective property value on items. If the site goes offline, set it to deprecated rank. If the formatter URI changes, add a new statement with preferred rank"@en ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1630" ] . + +wbbs:formatter-uri-for-rdf a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "string" ; + rdfs:label "formatter URI for RDF"@en ; + dcterms:description "formatter URI for RDF resource: URI template from which \"$1\" can be automatically replaced with the effective property value on items (it is the URI of the resources, not the URI of the RDF file describing it)"@en ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1921" ] . + +wbbs:inverse-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-property" ; + rdfs:label "inverse of"@en ; + dcterms:description "owl:inverseOf"@en ; + owl:equivalentProperty owl:inverseOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1696" ] . + +wbbs:depicts a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "depicts"@en ; + dcterms:description "entity visually depicted in an image, literarily described in a work, or otherwise incorporated into an audiovisual or other medium"@en ; + owl:equivalentProperty foaf:depicts ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P180" ] . + +wbbs:is-part-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "is part of"@en ; + dcterms:description "dcterms:isPartOf"@en ; + owl:equivalentProperty dcterms:isPartOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P361" ] . + +wbbs:same-as a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "same as"@en ; + dcterms:description "owl:sameAs"@en ; + owl:equivalentProperty owl:sameAs ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P2888" ] . + +wbbs:bundle a owl:Ontology ; + dcterms:contributor "Clair Kronk", + "David Lindemann", + "Ismael Olea", + "Tuukka Hastrup", + "Tomás Saorín", + "Juan Antonio Pastor Sánchez" ; + dcterms:description "An essential set of entities for new Wikibase instances, mapped with Wikidata and selected by practicalities and common use" ; + dcterms:license ; + dcterms:publisher ; + dcterms:rights "CC0 1.0 Universal" ; + dcterms:source ; + dcterms:title "The Wikibase bootstrap set" ; + vann:preferredNamespacePrefix "wbbs" ; + vann:preferredNamespaceUri wbbs: ; + owl:versionInfo "2025-10-21" . + + + rdfs:label "Wikibase Community User Group"@en . diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies/wikibase-bootstrap.ttl b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies/wikibase-bootstrap.ttl new file mode 100644 index 000000000..80e5dc617 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies/wikibase-bootstrap.ttl @@ -0,0 +1,95 @@ +@prefix dcterms: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix schema: . +@prefix skos: . +@prefix vann: . +@prefix wbb: . +@prefix wbbs: . + +wbbs:instance-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "instance of"@en ; + dcterms:description "rdf:type"@en ; + owl:equivalentProperty rdf:type ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P31" ] . + +wbbs:subclass-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "subclass of"@en ; + dcterms:description "rdfs:subClassOf"@en ; + owl:equivalentProperty rdfs:subClassOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P279" ] . + +wbbs:part-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "part of"@en ; + dcterms:description "schema:isPartOf"@en ; + owl:equivalentProperty schema:isPartOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P361" ] . + +wbbs:subproperty-of a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-property" ; + rdfs:label "subproperty of"@en ; + dcterms:description "rdfs:subPropertyOf"@en ; + owl:equivalentProperty rdfs:subPropertyOf ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1647" ] . + +wbbs:exact-match a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "url" ; + rdfs:label "exact match"@en ; + dcterms:description "skos:exactMatch"@en ; + owl:equivalentProperty skos:exactMatch ; + wbb:claim [ wbb:property wbbs:exact-match ; wbb:value ] ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P2888" ] . + +wbbs:wikidata-entity-id a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "external-id" ; + rdfs:label "Wikidata Entity ID"@en ; + dcterms:description "used to indicate a mapping (of some undefined nature, but can be further specified by individual Wikibases) from a Wikibase Item, Property, Lexeme, etc. to Wikidata"@en ; + wbb:claim [ wbb:property wbbs:formatter-uri ; wbb:value "http://www.wikidata.org/entity/$1" ] ; + wbb:claim [ wbb:property wbbs:formatter-uri-for-rdf ; wbb:value "http://www.wikidata.org/entity/$1" ] . + +wbbs:formatter-uri a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "string" ; + rdfs:label "formatter URI"@en ; + dcterms:description "web page URI; URI template from which \"$1\" can be automatically replaced with the effective property value on items. If the site goes offline, set it to deprecated rank. If the formatter URI changes, add a new statement with preferred rank"@en ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1630" ] . + +wbbs:formatter-uri-for-rdf a owl:DatatypeProperty ; + wbb:kind wbb:Property ; + wbb:datatype "string" ; + rdfs:label "formatter URI for RDF"@en ; + dcterms:description "formatter URI for RDF resource: URI template from which \"$1\" can be automatically replaced with the effective property value on items (it is the URI of the resources, not the URI of the RDF file describing it)"@en ; + wbb:claim [ wbb:property wbbs:wikidata-entity-id ; wbb:value "P1921" ] . + +wbbs:bundle a owl:Ontology ; + dcterms:contributor "Clair Kronk", + "David Lindemann", + "Ismael Olea", + "Tuukka Hastrup", + "User:Nikki" ; + dcterms:description "An essential set of entities for new Wikibase instances, mapped with Wikidata and selected by practicalities and common use" ; + dcterms:license ; + dcterms:publisher ; + dcterms:rights "CC0 1.0 Universal" ; + dcterms:source ; + dcterms:title "The Wikibase bootstrap set" ; + vann:preferredNamespacePrefix "wbbs" ; + vann:preferredNamespaceUri wbbs: ; + owl:versionInfo "2026-03-19" . + + + rdfs:label "Wikibase Community User Group"@en . diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap.css b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap.css new file mode 100644 index 000000000..0e687c8d1 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap.css @@ -0,0 +1,106 @@ +.ext-wikibase-bootstrap-catalog__table { + width: 100% !important; + max-width: none; + border-collapse: collapse; + table-layout: fixed; +} + +.ext-wikibase-bootstrap-catalog__table th, +.ext-wikibase-bootstrap-catalog__table td { + padding: 8px 12px; + text-align: left; +} + +.ext-wikibase-bootstrap-catalog__table thead th { + border-top: 1px solid var( --border-color-muted, #c8ccd1 ); + font-weight: 700; +} + +.ext-wikibase-bootstrap-catalog__ontology { + width: 34%; +} + +.ext-wikibase-bootstrap-catalog__ontology-cell { + font-weight: 600; + text-align: left; +} + +.ext-wikibase-bootstrap-catalog__action-column { + width: 15em; +} + +.ext-wikibase-bootstrap-catalog__table th, +.ext-wikibase-bootstrap-catalog__table td { + vertical-align: top; + background: transparent !important; +} + +.ext-wikibase-bootstrap-catalog__bundle-description { + margin: 8px 0; + font-weight: normal; +} + +.ext-wikibase-bootstrap-catalog__metadata { + display: grid; + grid-template-columns: max-content minmax( 0, 1fr ); + column-gap: 8px; + row-gap: 4px; + margin: 0; + font-size: 0.875em; + color: var( --color-subtle, #54595d ); +} + +.ext-wikibase-bootstrap-catalog__metadata > div { + display: contents; +} + +.ext-wikibase-bootstrap-catalog__metadata dt { + font-weight: 600; +} + +.ext-wikibase-bootstrap-catalog__metadata dd { + margin: 0; +} + +.ext-wikibase-bootstrap-catalog__summary { + color: var( --color-subtle, #54595d ); + font-weight: normal; +} + +.ext-wikibase-bootstrap-catalog__row > th, +.ext-wikibase-bootstrap-catalog__row > td { + border-bottom: 1px solid var( --border-color-subtle, #eaecf0 ); +} + +.ext-wikibase-bootstrap-catalog__row:last-child > th, +.ext-wikibase-bootstrap-catalog__row:last-child > td { + border-bottom: 0; +} + +.ext-wikibase-bootstrap-catalog__action { + vertical-align: top; +} + +.ext-wikibase-bootstrap-catalog__action-content { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.ext-wikibase-bootstrap-reset__confirmation { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin-top: 12px; +} + +.ext-wikibase-bootstrap-reset__confirmation label { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ext-wikibase-bootstrap-reset__confirmation .cdx-text-input { + width: 14em; +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap/OntologyBootstrapApp.vue b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap/OntologyBootstrapApp.vue new file mode 100644 index 000000000..74f260991 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap/OntologyBootstrapApp.vue @@ -0,0 +1,139 @@ + + + diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap/init.js b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap/init.js new file mode 100644 index 000000000..bb8499d87 --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/resources/ext.wikibaseBootstrap/init.js @@ -0,0 +1,7 @@ +const Vue = require( 'vue' ); +const OntologyBootstrapApp = require( './OntologyBootstrapApp.vue' ); + +const target = document.getElementById( 'wikibase-bootstrap-app' ); +if ( target ) { + Vue.createMwApp( OntologyBootstrapApp ).mount( target ); +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/LoadExtensions.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/LoadExtensions.php index 02ed144a1..f943a0404 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/LoadExtensions.php +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/LoadExtensions.php @@ -60,3 +60,4 @@ # Wikibase Suite integration wfLoadExtension( 'WikibaseSuite' ); +wfLoadExtension( 'WikibaseBootstrap' ); diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/Settings.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/Settings.php index 760cf372b..707253bc4 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/Settings.php +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/config/Settings.php @@ -15,6 +15,12 @@ require $wbsInstanceSettings; require __DIR__ . '/DefaultSettings.php'; + +$wbsBeforeExtensions = '/config/BeforeExtensions.php'; +if ( is_file( $wbsBeforeExtensions ) ) { + require $wbsBeforeExtensions; +} + require __DIR__ . '/LoadExtensions.php'; $wbsCustomSettings = '/config/LocalSettings.php'; diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/extension.json b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/extension.json index 50fe7b749..06c510edb 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/extension.json +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/extension.json @@ -15,9 +15,14 @@ "wikibasesuite": "MediaWiki\\Extension\\WikibaseSuite\\ApiQueryWikibaseSuite" }, "Hooks": { + "BeforeInitialize": "MediaWiki\\Extension\\WikibaseSuite\\Hooks::onBeforeInitialize", "BeforePageDisplay": "MediaWiki\\Extension\\WikibaseSuite\\Hooks::onBeforePageDisplay", "SoftwareInfo": "MediaWiki\\Extension\\WikibaseSuite\\Hooks::onSoftwareInfo", - "SidebarBeforeOutput": "MediaWiki\\Extension\\WikibaseSuite\\Hooks::onSidebarBeforeOutput" + "SidebarBeforeOutput": "MediaWiki\\Extension\\WikibaseSuite\\Hooks::onSidebarBeforeOutput", + "TestCanonicalRedirect": "MediaWiki\\Extension\\WikibaseSuite\\Hooks::onTestCanonicalRedirect" + }, + "SpecialPages": { + "WikibaseDashboard": "MediaWiki\\Extension\\WikibaseSuite\\SpecialWikibaseDashboard" }, "ResourceModules": { "ext.wikibasesuite.vector2022": { @@ -25,6 +30,19 @@ }, "ext.wikibasesuite.pinAnonymousMainMenu": { "scripts": ["resources/pinAnonymousMainMenu.js"] + }, + "ext.wikibasesuite.dashboard": { + "class": "MediaWiki\\ResourceLoader\\CodexModule", + "packageFiles": [ + "resources/ext.wikibasesuite.dashboard/init.js", + "resources/ext.wikibasesuite.dashboard/WikibaseDashboardApp.vue" + ], + "styles": ["resources/ext.wikibasesuite.dashboard/dashboard.css"], + "codexComponents": ["CdxMessage", "CdxButton"], + "dependencies": ["codex-styles"] + }, + "ext.wikibasesuite.dashboard.styles": { + "styles": ["resources/ext.wikibasesuite.dashboard.css"] } }, "ResourceFileModulePaths": { diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/i18n/en.json b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/i18n/en.json index c2b582192..a61faf0cd 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/i18n/en.json +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/i18n/en.json @@ -11,5 +11,6 @@ "wikibasesuite-sidebar-link-all-items": "All items", "wikibasesuite-sidebar-link-all-properties": "All properties", "wikibasesuite-sidebar-link-quickstatements": "QuickStatements", - "wikibasesuite-sidebar-link-sparql-query-service": "SPARQL Query Service" + "wikibasesuite-sidebar-link-sparql-query-service": "SPARQL Query Service", + "wikibasedashboard": "Dashboard" } diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Hooks.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Hooks.php index e8b413a97..85a08356a 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Hooks.php +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Hooks.php @@ -2,13 +2,79 @@ namespace MediaWiki\Extension\WikibaseSuite; -use OutputPage; -use Skin; -use SpecialPage; +use ExtensionRegistry; +use MediaWiki\Actions\ActionEntryPoint; +use MediaWiki\Extension\WikibaseBootstrap\BootstrapService; +use MediaWiki\Extension\WikibaseSuite\Utils; +use MediaWiki\MediaWikiServices; +use MediaWiki\Output\OutputPage; +use MediaWiki\Request\WebRequest; +use MediaWiki\Skin\Skin; +use MediaWiki\SpecialPage\SpecialPage; +use MediaWiki\Title\Title; +use MediaWiki\User\User; class Hooks { + + public static function onTestCanonicalRedirect( + WebRequest $request, + Title $title, + OutputPage $output + ): bool { + if ( self::redirectRootToDashboard( $request, $title, $output ) ) { + // We've set our own redirect, so skip core's redirect to /wiki/Main_Page. + return false; + } + return true; + } + + public static function onBeforeInitialize( + Title $title, + $unused, + OutputPage $output, + User $user, + WebRequest $request, + ActionEntryPoint $mediaWiki + ) { + self::redirectRootToDashboard( $request, $title, $output ); + } + + /** + * @return bool true if a redirect to the dashboard was set + */ + private static function redirectRootToDashboard( + WebRequest $request, + Title $title, + OutputPage $output + ): bool { + if ( !$title->isMainPage() ) { + return false; + } + + // Only redirect the domain root, not an explicit /wiki/Main_Page URL. + if ( parse_url( $request->getRequestURL(), PHP_URL_PATH ) !== '/' ) { + return false; + } + + if ( $request->getVal( 'redirect' ) === 'no' ) { + return false; + } + + if ( !self::isWikibaseEmpty() ) { + return false; + } + + $output->redirect( SpecialPage::getTitleFor( 'WikibaseDashboard' )->getLocalURL() ); + return true; + } + + private static function isWikibaseEmpty(): bool { + [ $itemCount, $propertyCount ] = Utils::getEntityCounts(); + return $itemCount === 0 && $propertyCount === 0; + } + public static function onRegistration(): void { - if ( !\ExtensionRegistry::getInstance()->isLoaded( 'WikibaseManifest' ) ) { + if ( !ExtensionRegistry::getInstance()->isLoaded( 'WikibaseManifest' ) ) { return; } @@ -68,13 +134,16 @@ public static function onSoftwareInfo( &$software ): bool { } public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): void { - // These links target Wikibase Repository special pages and namespaces. - // A client-only wiki does not define the repository namespace constants. if ( !defined( 'WB_NS_ITEM' ) ) { return; } $wikibaseLinks = [ + [ + 'text' => $skin->msg( 'wikibasedashboard' )->text(), + 'href' => SpecialPage::getTitleFor( 'WikibaseDashboard' )->getLocalURL(), + 'id' => 'n-wbs-link-one', + ], [ 'text' => $skin->msg( 'wikibasesuite-sidebar-link-create-item' )->text(), 'href' => SpecialPage::getTitleFor( 'NewItem' )->getLocalURL(), @@ -117,11 +186,34 @@ public static function onSidebarBeforeOutput( Skin $skin, array &$sidebar ): voi ]; } + $ontologyBootstrapUrl = self::ontologyBootstrapUrlFor( $skin->getUser() ); + if ( $ontologyBootstrapUrl !== null ) { + $wikibaseLinks[] = [ + 'text' => $skin->msg( 'ontologybootstrap' )->text(), + 'href' => $ontologyBootstrapUrl, + 'id' => 'n-ontology-bootstrap', + 'active' => false, + ]; + } + self::insertBeforeToolbox( $sidebar, [ 'wikibase-suite-sidebar' => $wikibaseLinks, ] ); } + public static function ontologyBootstrapUrlFor( User $user ): ?string { + if ( !ExtensionRegistry::getInstance()->isLoaded( 'WikibaseBootstrap' ) || + !MediaWikiServices::getInstance() + ->getSpecialPageFactory() + ->exists( 'OntologyBootstrap' ) || + !BootstrapService::newFromConfig()->isAvailableTo( $user ) + ) { + return null; + } + + return SpecialPage::getTitleFor( 'OntologyBootstrap' )->getLocalURL(); + } + private static function insertBeforeToolbox( array &$sidebar, array $sections ): void { $toolboxPosition = array_search( 'TOOLBOX', array_keys( $sidebar ), true ); if ( $toolboxPosition === false ) { diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/SpecialWikibaseDashboard.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/SpecialWikibaseDashboard.php new file mode 100644 index 000000000..2e61518bf --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/SpecialWikibaseDashboard.php @@ -0,0 +1,132 @@ +setHeaders(); + $this->outputHeader(); + + $output = $this->getOutput(); + $output->addModuleStyles( 'ext.wikibasesuite.dashboard.styles' ); + + [ $itemCount, $propertyCount ] = Utils::getEntityCounts(); + $tripleCount = $this->getTripleCount(); + + $newItemUrl = SpecialPage::getTitleFor( 'NewItem' )->getLocalURL(); + $newPropertyUrl = SpecialPage::getTitleFor( 'NewProperty' )->getLocalURL(); + $scriptPath = MediaWikiServices::getInstance()->getMainConfig()->get( 'ScriptPath' ); + $bannerImageUrl = $scriptPath . '/extensions/WikibaseSuite/resources/assets/wbs_dashboard_banner.png'; + + $output->addHTML( + '
' . + 'Wikibase' . + '
' + ); + + $output->addHTML( + '
' . + + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '' . + '
Your Wikibase Ontology
Items' . + '
' . + '' . htmlspecialchars( (string)$itemCount ) . '' . + 'Add item' . + '
' . + '
Properties' . + '
' . + '' . htmlspecialchars( (string)$propertyCount ) . '' . + 'Add property' . + '
' . + '
Triples' . + '
' . + '' . htmlspecialchars( (string)$tripleCount ) . '' . + '
' . + '
' . + + '' . + + '
' + ); + + $importPropertiesUrl = Hooks::ontologyBootstrapUrlFor( $this->getUser() ); + if ( $importPropertiesUrl !== null ) { + + $output->addHTML( + '' + ); + } + } + + + /** + * @return int + */ + private function getTripleCount(): ?int { + $config = MediaWikiServices::getInstance()->getMainConfig(); + $endpoint = $config->has( 'WBRepoSettings' ) + ? ( $config->get( 'WBRepoSettings' )['sparqlEndpoint'] ?? 0 ) + : 0; + + if ( $endpoint === 0 ) { + return 0; + } + + $query = 'SELECT (COUNT(*) AS ?count) WHERE { ?s ?p ?o }'; + $url = $endpoint . '?query=' . urlencode( $query ) . '&format=json'; + + $options = [ 'timeout' => 5 ]; + $req = MediaWikiServices::getInstance()->getHttpRequestFactory() + ->create( $url, $options, __METHOD__ ); + $req->setHeader( 'Accept', 'application/sparql-results+json' ); + + $status = $req->execute(); + if ( !$status->isOK() ) { + return 0; + } + + $data = json_decode( $req->getContent(), true ); + return isset( $data['results']['bindings'][0]['count']['value'] ) + ? (int)$data['results']['bindings'][0]['count']['value'] + : 0; + } + + public function getGroupName() { + return 'wikibase'; + } +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Utils.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Utils.php new file mode 100644 index 000000000..f49bd0c4b --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/includes/Utils.php @@ -0,0 +1,33 @@ +getEntityNamespace( 'item' ); + $propertyNs = $namespaceLookup->getEntityNamespace( 'property' ); + + $dbr = MediaWikiServices::getInstance() + ->getConnectionProvider() + ->getReplicaDatabase(); + + $count = static function ( ?int $ns ) use ( $dbr ): int { + if ( $ns === null ) { + return 0; + } + return (int)$dbr->newSelectQueryBuilder() + ->select( 'COUNT(*)' ) + ->from( 'page' ) + ->where( [ 'page_namespace' => $ns, 'page_is_redirect' => 0 ] ) + ->caller( __METHOD__ ) + ->fetchField(); + }; + + return [ $count( $itemNs ), $count( $propertyNs ) ]; + } +} \ No newline at end of file diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/resources/assets/wbs_dashboard_banner.png b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/resources/assets/wbs_dashboard_banner.png new file mode 100644 index 000000000..48b7de8c6 Binary files /dev/null and b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/resources/assets/wbs_dashboard_banner.png differ diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/resources/ext.wikibasesuite.dashboard.css b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/resources/ext.wikibasesuite.dashboard.css new file mode 100644 index 000000000..8ddd0160a --- /dev/null +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/resources/ext.wikibasesuite.dashboard.css @@ -0,0 +1,76 @@ +.wbs-dashboard-banner { + margin-bottom: 16px; +} + +.wbs-dashboard-banner img { + max-width: 100%; + height: auto; +} + +.wbs-dashboard-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 0 20px; +} + +.wbs-dashboard-table { + border-collapse: collapse; + flex: 0 0 auto; + width: 400px; + height: 185px; +} + +.wbs-dashboard-table tr { + height: 25%; +} + +.wbs-dashboard-table th { + border: 1px solid #a2a9b1; + padding: 6px 12px; + text-align: left; +} + +.wbs-dashboard-table td { + border: 1px solid #a2a9b1; + padding: 6px 12px; +} + +.wbs-dashboard-table td:first-child { + width: 35%; +} + +.wbs-dashboard-cell-value { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.wbs-dashboard-action-button { + width: 100px; + height: 30px; + font-size: 10px !important; +} + +.wbs-dashboard-help { + border: 1px solid #a2a9b1; + padding: 12px 16px; + flex: 1 1 auto; + max-width: 400px; + height: 160px; +} + +.wbs-dashboard-import-row { + margin-top: 16px; + padding-left: 20px; +} + +.wbs-dashboard-import-button { + width: 400px; +} + +.mw-special-WikibaseDashboard .vector-page-titlebar, +.mw-special-WikibaseDashboard .vector-page-toolbar { + display: none; +} diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/Configuration.php b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/Configuration.php index c5fb81be3..aecf6bf7a 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/Configuration.php +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/Configuration.php @@ -62,8 +62,9 @@ function phpAssignments( array $settings, ?string $elasticsearchHost = null ): s return $output; } -function customSettingsPreamble(): string { +function customSettingsPreamble( string $beforeExtensions = '' ): string { return " $value, 'type' => is_array( $token ) ? $token[0] : null, + 'start' => $offset, 'end' => $end, 'depth' => $depth, + ]; + if ( in_array( $text, [ '(', '[', '{' ], true ) ) { + $depth++; + } elseif ( in_array( $text, [ ')', ']', '}' ], true ) ) { + $depth--; + } + } + $offset = $end; + } + return $result; +} + +function legacyStatementMatches( array $tokens, string $statement ): array { + $fingerprint = array_column( legacyTokens( " $tokens[$i]['start'], + 'end' => $tokens[$i + $length - 1]['end'], + 'topLevel' => $tokens[$i]['depth'] === 0 && + ( $i === 0 || in_array( $values[$i - 1], [ ';', '}' ], true ) ), + ]; + } + } + return $matches; +} - if ( substr_count( $prefix, $mediaWikiLoader ) === 1 && - substr_count( $prefix, $extensionLoader ) === 1 +function loadingSignatures(): array { + // Fresh WBS 7 LocalSettings.php, from wikibase@7.1.0. Installations upgraded + // from earlier versions may still use the older section below. + $wbs7MediaWiki = "require_once '/LocalSettings.MediaWiki.php';"; + $wbs7Extensions = "require_once '/LocalSettings.Extensions.php';"; + + // The generated extension section is byte-identical in the WBS 1–6 + // LocalSettings.wbs.php templates (deploy@1.0.0 through deploy@6.0.0). + $wbs1To6Extensions = <<<'PHP' +foreach (glob("LocalSettings.d/*.php") as $filename) +{ + include $filename; +} +PHP; + return [ $wbs7MediaWiki, $wbs7Extensions, $wbs1To6Extensions ]; +} + +function failUnrecognizedLoadingSection(): never { + $message = <<<'MESSAGE' +Your config/LocalSettings.php could not be migrated automatically for use with +this version of the Wikibase image. The file is unchanged. + +The migration expects one of the following settings-loading sections in your +LocalSettings.php, as generated by earlier Wikibase images: + + For WBS 7: + # Managed by the Wikibase image. DO NOT REMOVE OR MOVE THIS LINE. + require_once '/LocalSettings.MediaWiki.php'; + + # Add MediaWiki configuration values here that must be set before bundled + # extensions are loaded. + + # Managed by the Wikibase image. DO NOT REMOVE OR MOVE THIS LINE. + require_once '/LocalSettings.Extensions.php'; + + For WBS 1–6 (including installations later upgraded to WBS 7): + # Load extensions if present, alphabetically ordered by filename + foreach (glob("LocalSettings.d/*.php") as $filename) + { + include $filename; + } + +Compare your file with these examples. If you can restore its original +settings-loading section, retry docker compose up -d. Do not add a second +copy of either section. + +For additional help from the Wikibase Suite team, open a ticket at: +https://phabricator.wikimedia.org/maniphest/task/edit/form/129/ +MESSAGE; + fwrite( STDERR, "$message\n" ); + // Distinguish an untouched recognition failure from later migration errors. + exit( 2 ); +} + +function adaptLegacyPrefix( string $prefix ): array { + $tokens = legacyTokens( $prefix ); + [ $mediaWikiSignature, $extensionsSignature, $inlineSignature ] = loadingSignatures(); + $mediaWiki = legacyStatementMatches( $tokens, $mediaWikiSignature ); + $extensions = legacyStatementMatches( $tokens, $extensionsSignature ); + // The WBS 1–6 layout can survive in a WBS 7 installation after upgrades. + $loop = legacyStatementMatches( $tokens, $inlineSignature ); + $extensionReplacement = "require '" . dirname( dirname( __DIR__ ) ) . "/config/LoadExtensions.php';"; + if ( count( $mediaWiki ) === 1 && count( $extensions ) === 1 && $loop === [] && + $mediaWiki[0]['topLevel'] && $extensions[0]['topLevel'] && + $mediaWiki[0]['start'] < $extensions[0]['start'] ) { - $prefix = str_replace( - $mediaWikiLoader, - "require '" . __DIR__ . "/Suite7Defaults.php';", - $prefix - ); - $prefix = str_replace( - $extensionLoader, - "require '" . dirname( dirname( __DIR__ ) ) . "/config/LoadExtensions.php';", - $prefix - ); - return [ $prefix, 'wbs-7' ]; + $replacements = [ + [ $extensions[0], $extensionReplacement ], + [ $mediaWiki[0], "require '" . __DIR__ . "/Suite7Defaults.php';" ], + ]; + $shape = 'wbs-7'; + } elseif ( count( $loop ) === 1 && $loop[0]['topLevel'] && $mediaWiki === [] && $extensions === [] ) { + $replacements = [ [ $loop[0], $extensionReplacement ] ]; + $shape = 'wbs-1-to-6'; + } else { + failUnrecognizedLoadingSection(); + } + // Replace from the end so the original offsets remain valid. + foreach ( $replacements as [ $match, $replacement ] ) { + $prefix = substr_replace( $prefix, $replacement, $match['start'], $match['end'] - $match['start'] ); } - fail( 'The generated LocalSettings.php is not a supported Wikibase Suite 7 configuration.' ); + return [ $prefix, $shape ]; +} + +function validateMigratedConfiguration( string $instancePath, string $customPath ): void { + foreach ( [ $instancePath, $customPath ] as $path ) { + if ( !is_readable( $path ) ) { + fail( "The prepared configuration is missing $path." ); + } + } + $custom = file_get_contents( $customPath ); + if ( $custom === false ) { + fail( "Could not read $customPath." ); + } + if ( preg_match( '/^# End of generated LocalSettings\.php\r?$/m', $custom ) ) { + fail( 'The prepared LocalSettings.php still has the historical generated-settings marker. ' . + 'Keep the original file for support to review.' ); + } + // Scan the entire result, including user settings below the old marker. + // Matching string tokens ignores harmless mentions in comments. + $obsolete = [ '/LocalSettings.MediaWiki.php', '/LocalSettings.Extensions.php', 'LocalSettings.d/*.php' ]; + $tokens = legacyTokens( $custom ); + foreach ( $tokens as $index => $token ) { + $path = trim( $token['value'], "'" ); + if ( !in_array( $path, $obsolete, true ) || $token['type'] !== T_CONSTANT_ENCAPSED_STRING ) { + continue; + } + for ( $i = $index - 1; $i >= 0; $i-- ) { + if ( in_array( $tokens[$i]['value'], [ ';', '{', '}' ], true ) ) { + break; + } + $isFileLoader = in_array( $tokens[$i]['type'], [ T_REQUIRE, T_REQUIRE_ONCE, T_INCLUDE, T_INCLUDE_ONCE ], true ); + $isLegacyGlob = $path === 'LocalSettings.d/*.php' && $tokens[$i]['value'] === 'glob'; + if ( $isFileLoader || $isLegacyGlob ) { + fail( 'The prepared LocalSettings.php still contains an active WBS 1–7 loader. ' . + 'Keep the original file for support to review.' ); + } + } + } +} + +function inheritedAnonymousPermissions( array $actual, array $reference ): string { + $settings = ''; + // Earlier installations did not load Suite7Defaults.php. Preserve their + // effective anonymous-access policy before loading user-owned Extensions.php. + foreach ( [ 'edit', 'createpage', 'createtalk', 'writeapi', 'createaccount' ] as $right ) { + $value = $actual['wgGroupPermissions']['*'][$right] ?? false; + if ( $value !== ( $reference['wgGroupPermissions']['*'][$right] ?? false ) ) { + $settings .= "\$wgGroupPermissions['*']['$right'] = " . var_export( $value, true ) . ";\n"; + } + } + return $settings === '' ? '' : "# Anonymous-access settings retained from the WBS 1–6 configuration.\n$settings\n"; } function legacyCustomTail( string $source, int $markerPosition ): string { @@ -169,8 +327,11 @@ function stageMigratedConfiguration( $migratedAssignments[] = $assignment['source']; } } - $custom = customSettingsPreamble(); - $custom .= "\n# Settings migrated from the generated Suite 7 LocalSettings.php.\n"; + [ , $shape ] = adaptLegacyPrefix( substr( $source, 0, $markerPosition ) ); + $custom = customSettingsPreamble( + $shape === 'wbs-1-to-6' ? inheritedAnonymousPermissions( $actual, $reference ) : '' + ); + $custom .= "\n# Settings migrated from the generated WBS 1–7 LocalSettings.php.\n"; $custom .= "# Some may be legacy values; review them after the upgrade.\n"; $custom .= "# Review the backup for custom executable PHP above the old generated marker.\n"; if ( $migratedAssignments !== [] ) { @@ -221,6 +382,12 @@ function installStagedFile( string $sourcePath, string $destinationPath ): void } installStagedFile( $argv[2], $argv[3] ); break; + case 'validate': + if ( count( $argv ) !== 4 ) { + fail( 'Usage: MigrateConfiguration.php validate INSTANCE CUSTOM' ); + } + validateMigratedConfiguration( $argv[2], $argv[3] ); + break; default: - fail( 'Expected stage-instance-settings, write-loadable-legacy-config, stage-migrated-configuration, or install.' ); + fail( 'Expected stage-instance-settings, write-loadable-legacy-config, stage-migrated-configuration, validate, or install.' ); } diff --git a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/migration/migrate.sh b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/migration/migrate.sh index 1f7e637e5..60d6e1580 100644 --- a/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/migration/migrate.sh +++ b/development/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/migration/migrate.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# Migrate a WBS 7 generated configuration to the current split configuration. +# Migrate a WBS 1–7 generated configuration retained by a WBS 7 installation. # This script owns all migration state so setup.sh can remain a concise image # lifecycle dispatcher. -set -eu +set -Eeuo pipefail migration_directory_path=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd) @@ -14,6 +14,38 @@ image_state_directory=/config/.wikibase-image migration_directory=$image_state_directory/config-migration staged_instance_settings=$migration_directory/InstanceSettings.php staged_custom_settings=$migration_directory/LocalSettings.php +original_custom_settings=$migration_directory/OriginalLocalSettings.php +support_ticket_url=https://phabricator.wikimedia.org/maniphest/task/edit/form/129/ + +on_exit() { + status=$1 + if [ "$status" -eq 0 ]; then + return + fi + # An unrecognized layout is rejected before creating migration state. PHP + # has already printed the complete guidance for this case. + if [ "$status" -eq 2 ] && [ ! -d "$migration_directory" ] && [ ! -e "$instance_settings" ]; then + return + fi + # A preparation failure has not touched active configuration. Discard only + # our working files so the operator can correct the original and retry. + if [ ! -d "$migration_directory" ] && [ ! -e "$instance_settings" ]; then + echo "The active configuration is unchanged." >&2 + elif [ -r "$original_custom_settings" ] && [ ! -e "$instance_settings" ] && \ + cmp -s "$custom_settings" "$original_custom_settings"; then + rm -rf "$migration_directory" + remove_image_state_directory_if_empty + echo "The active configuration is unchanged. Its pre-upgrade backup is in /config/backups/." >&2 + else + echo "Migration state was retained for recovery; active configuration may be partly installed." >&2 + fi + echo "For additional help from the Wikibase Suite team, open a ticket at:" >&2 + echo "$support_ticket_url" >&2 + if [ -d "$migration_directory" ] || [ -e /config/backups/LocalSettings.pre-wbs-8.php.backup ]; then + echo "Keep config/backups/ and any retained migration state for diagnosis." >&2 + fi +} +trap 'on_exit "$?"' EXIT ensure_image_state_directory() { mkdir -p "$image_state_directory" @@ -28,6 +60,22 @@ install_staged_configuration() { echo "The staged WBS 8 configuration migration is incomplete." exit 1 fi + if [ -e "$instance_settings" ] && ! cmp -s "$instance_settings" "$staged_instance_settings"; then + echo "InstanceSettings.php changed during migration; preserving it for support to review." >&2 + exit 1 + fi + if ! cmp -s "$custom_settings" "$original_custom_settings" && \ + ! cmp -s "$custom_settings" "$staged_custom_settings"; then + echo "LocalSettings.php changed during migration; preserving it for support to review." >&2 + exit 1 + fi + php "$migration_directory_path/MigrateConfiguration.php" validate \ + "$staged_instance_settings" "$staged_custom_settings" + php -l "$staged_instance_settings" > /dev/null + php -l "$staged_custom_settings" > /dev/null + if [ -f /config/Extensions.php ]; then + php -l /config/Extensions.php > /dev/null + fi php "$migration_directory_path/MigrateConfiguration.php" install \ "$staged_instance_settings" \ "$instance_settings" @@ -37,7 +85,7 @@ install_staged_configuration() { } is_recognized_legacy_configuration() { - grep -q '^# End of generated LocalSettings.php$' "$custom_settings" + grep -Eq $'^# End of generated LocalSettings[.]php\r?$' "$custom_settings" } migrate_legacy_configuration() { @@ -48,16 +96,17 @@ migrate_legacy_configuration() { temporary_instance=$migration_directory/InstanceSettings.php.tmp legacy_prefix=/tmp/LocalSettings.legacy-prefix.php + legacy_shape=$(php "$migration_directory_path/MigrateConfiguration.php" write-loadable-legacy-config \ + "$legacy_settings" \ + "$legacy_prefix") echo "Legacy LocalSettings.php found; preserving and migrating it." + echo "Recognized $legacy_shape generated configuration." ensure_image_state_directory mkdir -p "$backup_directory" "$migration_directory" if [ ! -e "$backup_directory/LocalSettings.pre-wbs-8.php.backup" ]; then cp "$legacy_settings" "$backup_directory/LocalSettings.pre-wbs-8.php.backup" fi - legacy_shape=$(php "$migration_directory_path/MigrateConfiguration.php" write-loadable-legacy-config \ - "$legacy_settings" \ - "$legacy_prefix") - echo "Recognized $legacy_shape generated configuration." + cp "$legacy_settings" "$original_custom_settings" php /var/www/html/maintenance/run.php getConfiguration \ --conf "$legacy_prefix" \ --format=json \ @@ -103,7 +152,7 @@ if [ -d "$migration_directory" ]; then resume_configuration_migration elif [ -e "$custom_settings" ]; then if ! is_recognized_legacy_configuration; then - echo "$custom_settings exists without $instance_settings and is not a recognized Wikibase Suite 7 configuration." + echo "$custom_settings exists without $instance_settings and is not a recognized WBS 1–7 generated configuration." exit 1 fi migrate_legacy_configuration diff --git a/development/tests/README.md b/development/tests/README.md index cfa874eda..8f68a9e70 100644 --- a/development/tests/README.md +++ b/development/tests/README.md @@ -14,6 +14,7 @@ Run the fast development-tooling tests and browser-based integration suites thro | `wdqs` | Integration | WDQS, its updater, and the WDQS frontend through the `wdqs` and `wdqs-frontend` Compose profiles. | | `quickstatements` | Integration | QuickStatements through the `quickstatements` Compose profile. | | `opensearch` | Integration | OpenSearch-backed search through the `opensearch` Compose profile. | +| `wikibase-bootstrap` | Integration | SHACL conformance of the bundled ontology bootstrap profiles. | | `wbs-tools` | Integration | WBS Tools lifecycle, including bootstrap selection, web configuration, deployment health, and administrator login. | Integration suite settings live in `tests//.conf.ts`. Each combines the published deployment Compose file with the shared test override and any suite-specific override. Test results are written beneath the suite's `results` directory; CI uploads them after a failure. diff --git a/development/tests/upgrade/configuration-recognition.php b/development/tests/upgrade/configuration-recognition.php new file mode 100644 index 000000000..e21e64367 --- /dev/null +++ b/development/tests/upgrade/configuration-recognition.php @@ -0,0 +1,121 @@ + [ 'pipe', 'r' ], 1 => [ 'pipe', 'w' ], 2 => [ 'pipe', 'w' ] ], + $pipes + ); + if ( !is_resource( $process ) ) { + throw new RuntimeException( 'Could not start the migration command.' ); + } + fclose( $pipes[0] ); + $output = stream_get_contents( $pipes[1] ); + $error = stream_get_contents( $pipes[2] ); + fclose( $pipes[1] ); + fclose( $pipes[2] ); + return [ proc_close( $process ), $output, $error ]; +} + +// Run standalone with PHP, or against the actual image from the upgrade suite. +$migration = $argv[1] ?? dirname( __DIR__, 2 ) . + '/images/wikibase/runtime/var/www/html/extensions/WikibaseSuite/setup/migration/MigrateConfiguration.php'; +$suite7 = file_get_contents( __DIR__ . '/fixtures/wbs-7/LocalSettings.php' ); +$inherited = file_get_contents( __DIR__ . '/fixtures/wbs-1-to-6/LocalSettings.php' ); +$mediaWiki = "require_once '/LocalSettings.MediaWiki.php';"; +$extensions = "require_once '/LocalSettings.Extensions.php';"; +$loop = <<<'PHP' +foreach (glob("LocalSettings.d/*.php") as $filename) +{ + include $filename; +} +PHP; + +$cases = [ + 'fresh WBS 7' => [ $suite7, 'wbs-7' ], + 'inherited WBS 1–6' => [ $inherited, 'wbs-1-to-6' ], + 'formatted includes and commented duplicates' => [ + str_replace( $mediaWiki, '// ' . $mediaWiki . "\nrequire_once /* loader */ \"/LocalSettings.MediaWiki.php\" ;", $suite7 ), + 'wbs-7', + ], + 'formatted historical loop' => [ + str_replace( $loop, "foreach ( glob( 'LocalSettings.d/*.php' ) as \$filename ) { /* bundled */ include \$filename; }", $inherited ), + 'wbs-1-to-6', + ], + 'CRLF settings' => [ str_replace( "\n", "\r\n", $inherited ), 'wbs-1-to-6' ], + 'marker mentioned in a comment' => [ + str_replace( ' [ str_replace( $mediaWiki, "$mediaWiki\n$mediaWiki", $suite7 ), null ], + 'duplicate loops' => [ str_replace( $loop, "$loop\n$loop", $inherited ), null ], + 'mixed loaders' => [ str_replace( $extensions, "$extensions\n$loop", $suite7 ), null ], + 'conditional loop' => [ str_replace( $loop, "if ( true ) { $loop }", $inherited ), null ], + 'conditional includes' => [ str_replace( $mediaWiki, "if ( true ) $mediaWiki", $suite7 ), null ], + 'reversed includes' => [ strtr( $suite7, [ $mediaWiki => $extensions, $extensions => $mediaWiki ] ), null ], + 'loader below marker' => [ str_replace( $extensions, '', $suite7 ) . "\n$extensions\n", null ], + 'custom loop path' => [ str_replace( 'LocalSettings.d/*.php', 'custom/*.php', $inherited ), null ], + 'missing marker' => [ str_replace( '# End of generated LocalSettings.php', '# Removed marker', $inherited ), null ], +]; + +$directory = sys_get_temp_dir() . '/wbs-recognition-' . bin2hex( random_bytes( 8 ) ); +mkdir( $directory, 0700 ); +try { + foreach ( $cases as $name => [ $source, $expectedShape ] ) { + file_put_contents( "$directory/LocalSettings.php", $source ); + @unlink( "$directory/prefix.php" ); + [ $status, $output, $error ] = runMigration( $migration, [ + 'write-loadable-legacy-config', "$directory/LocalSettings.php", "$directory/prefix.php" + ] ); + if ( file_get_contents( "$directory/LocalSettings.php" ) !== $source ) { + throw new RuntimeException( "$name: source configuration changed." ); + } + if ( $expectedShape === null ) { + if ( $status === 0 || file_exists( "$directory/prefix.php" ) || $error === '' ) { + throw new RuntimeException( "$name: unsupported configuration was accepted." ); + } + } else { + if ( $status !== 0 || trim( $output ) !== $expectedShape ) { + throw new RuntimeException( "$name: expected $expectedShape, got $output$error" ); + } + $prefix = file_get_contents( "$directory/prefix.php" ); + if ( substr_count( $prefix, '/config/LoadExtensions.php' ) !== 1 || + str_contains( $prefix, "wfLoadExtension( 'WikibaseEdtf' );" ) || + ( $expectedShape === 'wbs-1-to-6' && str_contains( $prefix, 'Suite7Defaults.php' ) ) + ) { + throw new RuntimeException( "$name: incorrect loader adaptation or custom tail included." ); + } + } + } + echo "All configuration recognition checks passed.\n"; + + file_put_contents( "$directory/InstanceSettings.php", " [ + " [ " [ " [ " [ " [ $source, $allowed ] ) { + file_put_contents( "$directory/LocalSettings.php", $source ); + [ $status ] = runMigration( $migration, [ + 'validate', "$directory/InstanceSettings.php", "$directory/LocalSettings.php" + ] ); + if ( ( $status === 0 ) !== $allowed ) { + throw new RuntimeException( "$name: unexpected prepared configuration validation result." ); + } + } + echo "Prepared configuration validation checks passed.\n"; +} finally { + foreach ( glob( "$directory/*" ) as $file ) { + unlink( $file ); + } + rmdir( $directory ); +} diff --git a/development/tests/upgrade/configuration-recognition.spec.ts b/development/tests/upgrade/configuration-recognition.spec.ts new file mode 100644 index 000000000..91098f01d --- /dev/null +++ b/development/tests/upgrade/configuration-recognition.spec.ts @@ -0,0 +1,9 @@ +describe( 'Legacy configuration recognition', function () { + it( 'accepts historical loaders and harmless formatting while rejecting ambiguous layouts', async function () { + const output = await testEnv.runDockerComposeCmd( + 'exec -T wikibase php /migration-tests/configuration-recognition.php ' + + '/var/www/html/extensions/WikibaseSuite/setup/migration/MigrateConfiguration.php' + ); + expect( output ).toContain( 'All configuration recognition checks passed.' ); + } ); +} ); diff --git a/development/tests/upgrade/docker-compose.override.yml b/development/tests/upgrade/docker-compose.override.yml index e5fdb84bf..d44586468 100644 --- a/development/tests/upgrade/docker-compose.override.yml +++ b/development/tests/upgrade/docker-compose.override.yml @@ -1,4 +1,7 @@ services: + wikibase: + volumes: + - ./upgrade:/migration-tests:ro mysql: volumes: - ./upgrade/fixtures/wbs-7/database.sql.gz:/docker-entrypoint-initdb.d/10-wbs-7.sql.gz:ro diff --git a/development/tests/upgrade/fixtures/wbs-1-to-6/LocalSettings.php b/development/tests/upgrade/fixtures/wbs-1-to-6/LocalSettings.php new file mode 100644 index 000000000..27ecf0cac --- /dev/null +++ b/development/tests/upgrade/fixtures/wbs-1-to-6/LocalSettings.php @@ -0,0 +1,184 @@ + 'https://static.example/custom-logo.svg', + 'icon' => "$wgResourceBasePath/resources/assets/change-your-logo-icon.svg", +]; + +## UPO means: this is also a user preference option + +$wgEnableEmail = true; +$wgEnableUserEmail = true; # UPO + +$wgEmergencyContact = "owner@example.test"; +$wgPasswordSender = ""; + +$wgEnotifUserTalk = false; # UPO +$wgEnotifWatchlist = false; # UPO +$wgEmailAuthentication = true; + +## Database settings +$wgDBtype = "mysql"; +$wgDBserver = "mysql:3306"; +$wgDBname = "my_wiki"; +$wgDBuser = "sqluser"; +$wgDBpassword = "test-db-password"; + +# MySQL specific settings +$wgDBprefix = ""; +$wgDBssl = false; + +# MySQL table options to use during installation or update +$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary"; + +# Shared database table +# This has no effect unless $wgSharedDB is also set. +$wgSharedTables[] = "actor"; + +## Shared memory settings +$wgMainCacheType = CACHE_ACCEL; +$wgMemCachedServers = []; + +## To enable image uploads, make sure the 'images' directory +## is writable, then set this to true: +$wgEnableUploads = false; +$wgUseImageMagick = true; +$wgImageMagickConvertCommand = "/usr/bin/convert"; + +# InstantCommons allows wiki to use images from https://commons.wikimedia.org +$wgUseInstantCommons = false; + +# Periodically send a pingback to https://www.mediawiki.org/ with basic data +# about this MediaWiki instance. The Wikimedia Foundation shares this data +# with MediaWiki developers to help guide future development efforts. +$wgPingback = false; + +# Site language code, should be one of the list in ./includes/languages/data/Names.php +$wgLanguageCode = "en"; + +# Time zone +$wgLocaltimezone = "UTC"; + +## Set $wgCacheDirectory to a writable directory on the web server +## to make your wiki go slightly faster. The directory should not be +## publicly accessible from the web. +#$wgCacheDirectory = "$IP/cache"; + +$wgSecretKey = "0000000000000000000000000000000000000000000000000000000000000000"; + +# Changing this will log out all existing sessions. +$wgAuthenticationTokenVersion = "1"; + +# Site upgrade key. Must be set to a string (default provided) to turn on the +# web installer while LocalSettings.php is in place +$wgUpgradeKey = "0000000000000000"; + +## For attaching licensing metadata to pages, and displaying an +## appropriate copyright notice / icon. GNU Free Documentation +## License and Creative Commons licenses are supported so far. +$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright +$wgRightsUrl = ""; +$wgRightsText = ""; +$wgRightsIcon = ""; + +# Path to the GNU diff3 utility. Used for conflict resolution. +$wgDiff3 = "/usr/bin/diff3"; + +## Default skin: you can change the default skin. Use the internal symbolic +## names, e.g. 'vector' or 'monobook': +$wgDefaultSkin = "monobook"; + +# Enabled skins. +# The following skins were automatically enabled: +wfLoadSkin( 'MinervaNeue' ); +wfLoadSkin( 'MonoBook' ); +wfLoadSkin( 'Timeless' ); +wfLoadSkin( 'Vector' ); + + +# End of automatically generated settings. +# Add more configuration options below. + + +# Configuration added by Wikibase Suite installer in entrypoint.sh + +$elasticsearchHost = 'elasticsearch'; + + +# JobRunner instance is assumed by default, so jobs on request is disabled +$wgJobRunRate = 0; + +# File Uploads enabled by default +$wgEnableUploads = true; + +# Logs +# TODO: Explore simply logging to stdout/stderr so these appear in Docker logs +$wgDebugLogGroups = array( + 'resourceloader' => '/var/log/mediawiki/mw.resourceloader.log', + 'exception' => '/var/log/mediawiki/mw.exception.log', + 'error' => '/var/log/mediawiki/mw.error.log', + 'fatal' => '/var/log/mediawiki/mw.fatal.log', +); +$wgDebugLogFile = '/var/log/mediawiki/mw.debug.log'; + +$wgArticlePath = "/wiki/$1"; + +# Add configuration values here or above which should be set before extensions are loaded + +$wgJobRunRate = 0.5; + +# Load extensions if present, alphabetically ordered by filename +foreach (glob("LocalSettings.d/*.php") as $filename) +{ + include $filename; +} + +############################################################################## +# End of generated LocalSettings.php +############################################################################## + +# Add configuration values below which should be set after extensions are loaded +$wgAllowExternalImages = true; +wfLoadExtension( 'WikibaseEdtf' ); diff --git a/development/tests/upgrade/fixtures/wbs-1-to-6/README.md b/development/tests/upgrade/fixtures/wbs-1-to-6/README.md new file mode 100644 index 000000000..25b6f522d --- /dev/null +++ b/development/tests/upgrade/fixtures/wbs-1-to-6/README.md @@ -0,0 +1,13 @@ +# Configuration inherited by WBS 7 + +This fixture represents a WBS 7 installation that retained its configuration +from WBS 1, 2, 3, 4, 5, or 6. It uses the WBS-generated block from +`deploy@6.0.0:build/wikibase/LocalSettings.wbs.php`, which is byte-for-byte +identical in the latest audited WBS 1–5 deployment and image tags. The +matching loading section is recorded in `MigrateConfiguration.php`. + +The MediaWiki-generated prefix and test instance values come from the existing +`wbs-7/LocalSettings.php` fixture. A job-rate override before the extension loop +and custom settings after the marker exercise preservation of user changes. +The suite uses the WBS 7 database fixture to test conversion of a historical +configuration retained by a WBS 7 installation. diff --git a/development/tests/upgrade/pre-wbs-8-configuration-migration.spec.ts b/development/tests/upgrade/pre-wbs-8-configuration-migration.spec.ts new file mode 100644 index 000000000..4832085e9 --- /dev/null +++ b/development/tests/upgrade/pre-wbs-8-configuration-migration.spec.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const configurationDirectory = resolve( process.cwd(), 'upgrade/tmp/config' ); +const localSettingsPath = resolve( configurationDirectory, 'LocalSettings.php' ); +const instanceSettingsPath = resolve( configurationDirectory, 'InstanceSettings.php' ); +const backupPath = resolve( configurationDirectory, 'backups/LocalSettings.pre-wbs-8.php.backup' ); +const migrationStatePath = resolve( configurationDirectory, '.wikibase-image/config-migration' ); + +async function prepareLegacyConfiguration( contents: string ): Promise { + await testEnv.runDockerComposeCmd( 'stop wikibase wikibase-jobrunner' ); + rmSync( configurationDirectory, { recursive: true, force: true } ); + mkdirSync( configurationDirectory, { recursive: true } ); + writeFileSync( localSettingsPath, contents ); +} + +for ( const [ configurationShape, fixtureDirectory ] of [ + [ 'WBS 7 split settings', 'wbs-7' ], + [ 'WBS 1–6 inline settings', 'wbs-1-to-6' ], +] ) { + describe( 'Pre-WBS 8 configuration migration (' + configurationShape + ')', function () { + it( 'migrates the configuration', async function () { + const fixture = readFileSync( + resolve( process.cwd(), 'upgrade/fixtures/' + fixtureDirectory + '/LocalSettings.php' ), + 'utf8' + ); + await prepareLegacyConfiguration( fixture ); + if ( fixtureDirectory === 'wbs-1-to-6' ) { + // Earlier rejected attempts may leave an incomplete staging directory. + mkdirSync( migrationStatePath, { recursive: true } ); + } + await testEnv.runDockerComposeCmd( 'up -d --wait --wait-timeout 180 wikibase wikibase-jobrunner' ); + + const instanceSettings = readFileSync( instanceSettingsPath, 'utf8' ); + const localSettings = readFileSync( localSettingsPath, 'utf8' ); + expect( readFileSync( backupPath, 'utf8' ) ).toBe( fixture ); + expect( existsSync( migrationStatePath ) ).toBe( false ); + expect( instanceSettings ).toContain( "$wgDBpassword = 'test-db-password';" ); + expect( instanceSettings ).not.toContain( 'custom-logo.svg' ); + expect( localSettings ).toContain( "$wgJobRunRate = 0.5;" ); + expect( localSettings ).toContain( "$wgAllowExternalImages = true;" ); + expect( localSettings ).toContain( "wfLoadExtension( 'WikibaseEdtf' );" ); + expect( localSettings ).toContain( "require_once __DIR__ . '/Extensions.php';" ); + for ( const oldLoader of [ + '/LocalSettings.MediaWiki.php', + '/LocalSettings.Extensions.php', + 'LocalSettings.d/*.php' + ] ) { + expect( localSettings ).not.toContain( oldLoader ); + } + + const configurationOutput = await testEnv.runDockerComposeCmd( + 'exec -T wikibase php /var/www/html/maintenance/run.php getConfiguration --format=json --json-partial-output-on-error' + ); + const effective = JSON.parse( configurationOutput.slice( + configurationOutput.indexOf( '{' ), configurationOutput.lastIndexOf( '}' ) + 1 + ) ); + for ( const right of [ 'edit', 'createpage', 'createtalk', 'createaccount' ] ) { + expect( effective.wgGroupPermissions[ '*' ][ right ] ).toBe( fixtureDirectory === 'wbs-1-to-6' ); + } + expect( effective.wgGroupPermissions[ '*' ].writeapi ).toBe( false ); + + await browser.url( '/wiki/Main_Page' ); + await expect( $( 'body' ) ).toHaveElementClass( 'skin-monobook' ); + await expect( $( '#firstHeading' ) ).toHaveText( 'Main Page' ); + } ); + } ); +} + +describe( 'Pre-WBS 8 configuration rejection', function () { + it( 'leaves an unrecognized file untouched and migrates it after correction', async function () { + const fixture = readFileSync( + resolve( process.cwd(), 'upgrade/fixtures/wbs-7/LocalSettings.php' ), 'utf8' + ); + const include = "require_once '/LocalSettings.MediaWiki.php';"; + const unrecognized = fixture.replace( include, include + '\n' + include ); + await prepareLegacyConfiguration( unrecognized ); + const migrationCommand = 'run --rm --no-deps --entrypoint /bin/bash wikibase ' + + '/var/www/html/extensions/WikibaseSuite/setup/migration/migrate.sh'; + let errorMessage = ''; + try { + await testEnv.runDockerComposeCmd( migrationCommand ); + } catch ( error ) { + errorMessage = String( error ); + } + for ( const text of [ + 'The file is unchanged.', + 'For WBS 7:', + 'For WBS 1–6', + 'https://phabricator.wikimedia.org/maniphest/task/edit/form/129/' + ] ) { + expect( errorMessage ).toContain( text ); + } + expect( readFileSync( localSettingsPath, 'utf8' ) ).toBe( unrecognized ); + expect( existsSync( instanceSettingsPath ) ).toBe( false ); + expect( existsSync( backupPath ) ).toBe( false ); + expect( existsSync( migrationStatePath ) ).toBe( false ); + + writeFileSync( localSettingsPath, fixture ); + await testEnv.runDockerComposeCmd( 'up -d --wait --wait-timeout 180 wikibase wikibase-jobrunner' ); + expect( existsSync( instanceSettingsPath ) ).toBe( true ); + expect( readFileSync( localSettingsPath, 'utf8' ) ).not.toContain( include ); + } ); +} ); diff --git a/development/tests/upgrade/wbs-7-to-8.spec.ts b/development/tests/upgrade/wbs-7-to-8.spec.ts deleted file mode 100644 index 02ab32e16..000000000 --- a/development/tests/upgrade/wbs-7-to-8.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { existsSync, readFileSync, statSync } from 'node:fs'; -import { resolve } from 'node:path'; - -const configurationDirectory = resolve( process.cwd(), 'upgrade/tmp/config' ); - -describe( 'Wikibase Suite 7 to 8 upgrade', function () { - it( 'migrates the generated and user-owned configuration on startup', async function () { - const instanceSettingsPath = resolve( - configurationDirectory, - 'InstanceSettings.php' - ); - const localSettingsPath = resolve( configurationDirectory, 'LocalSettings.php' ); - const backupPath = resolve( - configurationDirectory, - 'backups/LocalSettings.pre-wbs-8.php.backup' - ); - const instanceSettings = readFileSync( instanceSettingsPath, 'utf8' ); - const localSettings = readFileSync( localSettingsPath, 'utf8' ); - const backup = readFileSync( backupPath, 'utf8' ); - const fixture = readFileSync( - resolve( process.cwd(), 'upgrade/fixtures/wbs-7/LocalSettings.php' ), - 'utf8' - ); - - expect( instanceSettings ).toContain( "$wgDBpassword = 'test-db-password';" ); - expect( instanceSettings ).toContain( - "$wgSecretKey = '0000000000000000000000000000000000000000000000000000000000000000';" - ); - expect( instanceSettings ).toContain( - "$elasticsearchHost = 'elasticsearch';" - ); - expect( instanceSettings ).not.toContain( 'custom-logo.svg' ); - - expect( localSettings ).toContain( "require_once __DIR__ . '/Extensions.php';" ); - expect( localSettings ).toContain( - "'1x' => 'https://static.example/custom-logo.svg'" - ); - expect( localSettings ).toContain( '$wgEmergencyContact = "owner@example.test";' ); - expect( localSettings ).toContain( '$wgDefaultSkin = "monobook";' ); - expect( localSettings ).toContain( '$wgJobRunRate = 0.5;' ); - expect( localSettings ).toContain( '$wgAllowExternalImages = true;' ); - expect( localSettings ).toContain( "wfLoadExtension( 'WikibaseEdtf' );" ); - expect( localSettings ).not.toContain( '$wgEnableEmail = true;' ); - const migratedInSourceOrder = [ - "'1x' => 'https://static.example/custom-logo.svg'", - '$wgEmergencyContact = "owner@example.test";', - '$wgDefaultSkin = "monobook";', - '$wgJobRunRate = 0.5;', - '$wgAllowExternalImages = true;', - "wfLoadExtension( 'WikibaseEdtf' );" - ].map( ( setting ) => localSettings.indexOf( setting ) ); - expect( migratedInSourceOrder.every( ( position ) => position >= 0 ) ).toBe( - true - ); - expect( migratedInSourceOrder ).toEqual( - [ ...migratedInSourceOrder ].sort( ( left, right ) => left - right ) - ); - expect( backup ).toBe( fixture ); - expect( statSync( instanceSettingsPath ).mode & 0o777 ).toBe( 0o644 ); - expect( statSync( localSettingsPath ).mode & 0o777 ).toBe( 0o644 ); - expect( - existsSync( resolve( configurationDirectory, '.wikibase-image' ) ) - ).toBe( false ); - - await browser.url( '/wiki/Main_Page' ); - await expect( $( 'body' ) ).toHaveElementClass( 'skin-monobook' ); - await expect( $( '#firstHeading' ) ).toHaveText( 'Main Page' ); - } ); -} ); diff --git a/development/tests/wikibase-bootstrap/bundle-validation.spec.ts b/development/tests/wikibase-bootstrap/bundle-validation.spec.ts new file mode 100644 index 000000000..b2e4d0c79 --- /dev/null +++ b/development/tests/wikibase-bootstrap/bundle-validation.spec.ts @@ -0,0 +1,12 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const validationScript = fileURLToPath( + new URL( './validate-bundles.sh', import.meta.url ) +); + +describe( 'Bundled ontology profiles', function () { + it( 'conform to the SHACL validation profiles', function () { + execFileSync( validationScript, { stdio: 'inherit' } ); + } ); +} ); diff --git a/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-importer-compatibility.shacl.ttl b/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-importer-compatibility.shacl.ttl new file mode 100644 index 000000000..63adea16d --- /dev/null +++ b/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-importer-compatibility.shacl.ttl @@ -0,0 +1,100 @@ +@prefix dcterms: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix skos: . +@prefix wbbs: . + +# This shape describes the restricted RDF convention consumed by upstream +# ttl2wb.py. It is not a general OWL profile and does not prescribe the +# Suite's local-ID-safe bootstrap profile. + +wbbs:ImporterEntityShape + a sh:NodeShape ; + sh:targetClass owl:Class, owl:NamedIndividual, owl:ObjectProperty, owl:DatatypeProperty ; + sh:property [ + sh:path rdfs:label ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + ] ; + sh:property [ + sh:path dcterms:description ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + ] . + +wbbs:ImporterItemShape + a sh:NodeShape ; + sh:targetClass owl:Class, owl:NamedIndividual ; + sh:property [ + sh:path skos:note ; + sh:qualifiedValueShape [ + sh:nodeKind sh:Literal ; + sh:pattern "^Wikibase ID: Q[1-9][0-9]*$" ; + ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] . + +wbbs:ImporterPropertyShape + a sh:NodeShape ; + sh:targetClass owl:ObjectProperty, owl:DatatypeProperty ; + sh:property [ + sh:path skos:note ; + sh:qualifiedValueShape [ + sh:nodeKind sh:Literal ; + sh:pattern "^Wikibase ID: P[1-9][0-9]*$" ; + ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + sh:property [ + sh:path skos:note ; + sh:qualifiedValueShape [ + sh:nodeKind sh:Literal ; + sh:pattern "^Wikibase datatype: [A-Za-z][A-Za-z-]*$" ; + ] ; + sh:qualifiedMinCount 1 ; + sh:qualifiedMaxCount 1 ; + ] ; + sh:property [ + sh:path owl:equivalentProperty ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path wbbs:P1 ; + sh:nodeKind sh:Literal ; + ] ; + sh:property [ + sh:path wbbs:P4 ; + sh:nodeKind sh:Literal ; + ] ; + sh:property [ + sh:path wbbs:P6 ; + sh:nodeKind sh:Literal ; + ] ; + sh:property [ + sh:path wbbs:P7 ; + sh:nodeKind sh:Literal ; + ] ; + sh:property [ + sh:path wbbs:P8 ; + sh:nodeKind sh:Literal ; + ] . + +wbbs:ImporterReferenceShape + a sh:NodeShape ; + sh:targetSubjectsOf rdfs:subClassOf, rdfs:subPropertyOf, owl:inverseOf ; + sh:property [ + sh:path rdfs:subClassOf ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path rdfs:subPropertyOf ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path owl:inverseOf ; + sh:nodeKind sh:IRI ; + ] . diff --git a/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-profile-example.ttl b/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-profile-example.ttl new file mode 100644 index 000000000..67f13bf77 --- /dev/null +++ b/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-profile-example.ttl @@ -0,0 +1,30 @@ +@prefix dcterms: . +@prefix owl: . +@prefix rdfs: . +@prefix wbb: . +@prefix example: . + +# These IRIs are source keys. They do not prescribe local P or Q identifiers. + +example:instance-of + a owl:ObjectProperty ; + wbb:kind wbb:Property ; + wbb:datatype "wikibase-item" ; + rdfs:label "instance of"@en ; + dcterms:description "The class of which this entity is an instance."@en . + +example:class + a owl:Class ; + wbb:kind wbb:Item ; + rdfs:label "class"@en ; + dcterms:description "A group of entities with shared characteristics."@en . + +example:person + a owl:Class ; + wbb:kind wbb:Item ; + rdfs:label "person"@en ; + dcterms:description "An individual human being."@en ; + wbb:claim [ + wbb:property example:instance-of ; + wbb:value example:class ; + ] . diff --git a/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-profile.shacl.ttl b/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-profile.shacl.ttl new file mode 100644 index 000000000..71a0ca489 --- /dev/null +++ b/development/tests/wikibase-bootstrap/fixtures/wikibase-bootstrap-profile.shacl.ttl @@ -0,0 +1,111 @@ +@prefix dcterms: . +@prefix owl: . +@prefix rdf: . +@prefix rdfs: . +@prefix sh: . +@prefix wbb: . + +# Wikibase Suite bootstrap profile, version 1. +# Entity IRIs are source keys. An importer assigns local P/Q IDs and resolves +# wbb:claim references using the source-IRI-to-local-ID mapping for that run. + +wbb:BootstrapEntityShape + a sh:NodeShape ; + sh:targetSubjectsOf wbb:kind ; + sh:nodeKind sh:IRI ; + sh:property [ + sh:path wbb:kind ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( wbb:Item wbb:Property wbb:SeedItem ) ; + ] ; + sh:property [ + sh:path rdfs:label ; + sh:minCount 1 ; + sh:nodeKind sh:Literal ; + ] ; + sh:property [ + sh:path dcterms:description ; + sh:nodeKind sh:Literal ; + ] . + +wbb:BootstrapPropertyShape + a sh:NodeShape ; + sh:target [ + a sh:SPARQLTarget ; + sh:select """ + SELECT ?this WHERE { ?this wbb:kind wbb:Property . } + """ ; + ] ; + sh:property [ + sh:path wbb:datatype ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:in ( + "wikibase-item" "wikibase-property" "string" "external-id" "url" + "time" "quantity" "monolingualtext" "globe-coordinate" "commonsMedia" + ) ; + ] . + +wbb:BootstrapClaimShape + a sh:NodeShape ; + sh:targetObjectsOf wbb:claim ; + sh:property [ + sh:path wbb:property ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:nodeKind sh:IRI ; + sh:node [ + sh:property [ + sh:path wbb:kind ; + sh:hasValue wbb:Property ; + ] ; + ] ; + ] ; + sh:property [ + sh:path wbb:value ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:or ( + [ sh:nodeKind sh:IRI ] + [ sh:nodeKind sh:Literal ] + ) ; + ] . + +wbb:BootstrapReferenceShape + a sh:NodeShape ; + sh:targetSubjectsOf rdfs:subClassOf, rdfs:subPropertyOf, owl:inverseOf ; + sh:property [ + sh:path rdfs:subClassOf ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path rdfs:subPropertyOf ; + sh:nodeKind sh:IRI ; + ] ; + sh:property [ + sh:path owl:inverseOf ; + sh:nodeKind sh:IRI ; + ] . + +# The upstream converter materializes every owl:equivalentProperty assertion +# as an exact-match property statement. The Suite profile must carry the same +# instruction explicitly because its importer intentionally does not interpret +# arbitrary OWL predicates at runtime. +wbb:EquivalentPropertyClaimShape + a sh:NodeShape ; + sh:targetSubjectsOf owl:equivalentProperty ; + sh:sparql [ + sh:message "Every owl:equivalentProperty requires a matching exact-match bootstrap claim." ; + sh:select """ + SELECT $this WHERE { + $this owl:equivalentProperty ?equivalentProperty . + FILTER NOT EXISTS { + $this wbb:claim [ + wbb:property ; + wbb:value ?equivalentProperty + ] . + } + } + """ ; + ] . diff --git a/development/tests/wikibase-bootstrap/validate-bundles.sh b/development/tests/wikibase-bootstrap/validate-bundles.sh new file mode 100755 index 000000000..96850ef80 --- /dev/null +++ b/development/tests/wikibase-bootstrap/validate-bundles.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -euo pipefail + +test_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_dir="$(cd "$test_dir/../../.." && pwd)" +fixture_dir="$test_dir/fixtures" +bundle_dir="$repository_dir/development/images/wikibase/runtime/var/www/html/extensions/WikibaseBootstrap/ontologies" +profile="$fixture_dir/wikibase-bootstrap-profile.shacl.ttl" + +for bundle in \ + "$bundle_dir/wikibase-bootstrap.ttl" \ + "$bundle_dir/wikibase-bootstrap-extended.ttl" \ + "$fixture_dir/wikibase-bootstrap-profile-example.ttl"; do + python3 -m pyshacl -s "$profile" "$bundle" +done diff --git a/development/tests/wikibase-bootstrap/wikibase-bootstrap.conf.ts b/development/tests/wikibase-bootstrap/wikibase-bootstrap.conf.ts new file mode 100644 index 000000000..b8cf633ff --- /dev/null +++ b/development/tests/wikibase-bootstrap/wikibase-bootstrap.conf.ts @@ -0,0 +1,11 @@ +import { defaultSettings } from '../_setup/make-test-settings.js'; +import TestEnv from '../_setup/test-env.js'; +import wdioConfig from '../_setup/wdio.conf.js'; + +export const testEnv = TestEnv.create( { + ...defaultSettings, + name: 'wikibase-bootstrap', + specs: [ 'wikibase-bootstrap/*.spec.ts' ] +} ); + +export const config = wdioConfig( testEnv ); diff --git a/development/tests/wikibase-extensions/LocalSettings.php b/development/tests/wikibase-extensions/LocalSettings.php index baf36e7c2..8a10f3fff 100644 --- a/development/tests/wikibase-extensions/LocalSettings.php +++ b/development/tests/wikibase-extensions/LocalSettings.php @@ -8,4 +8,3 @@ // Load user defined extension wfLoadExtension( 'extensions/WikibaseSuiteTestExtension' ); - diff --git a/development/tests/wikibase-extensions/wikibase-dashboard.spec.ts b/development/tests/wikibase-extensions/wikibase-dashboard.spec.ts new file mode 100644 index 000000000..3d083b9d6 --- /dev/null +++ b/development/tests/wikibase-extensions/wikibase-dashboard.spec.ts @@ -0,0 +1,67 @@ +import WikibaseApi from 'wdio-wikibase/wikibase.api.js'; +import Page from "../_helpers/pages/page.js"; + +async function hasEntitiesOfType( type: 'item' | 'property' ): Promise { + const api = await WikibaseApi.getApi(); + + const namespace = type === 'item' ? 120 : 122; + + const response = await api.request( { + action: 'query', + list: 'allpages', + apnamespace: namespace, + aplimit: 1, + } ); + + return ( response.query?.allpages?.length ?? 0 ) > 0; +} + +async function isWikibaseEmpty(): Promise { + const hasItems = await hasEntitiesOfType('item'); + const hasProperties = await hasEntitiesOfType('property'); + + return !hasItems && !hasProperties; +} + +describe( 'WikibaseSuite Dashboard', function () { + + before( async function () { + await browser.skipIfExtensionNotPresent( this, 'WikibaseSuite' ); + } ); + + it( 'Should redirect Main Page to the dashboard only while the wikibase is empty', async function () { + await Page.open( "/" ); + + const isEmpty = await isWikibaseEmpty(); + expect( isEmpty ).toBe( true ); + + const url = await browser.getUrl(); + expect( url ).toContain( 'Special:WikibaseDashboard' ); + } ); + + it( 'Should stop redirecting to the dashboard once an item exists', async function () { + await WikibaseApi.createItem( 'dashboard-redirect-test' ); + + const isEmpty = await isWikibaseEmpty(); + expect( isEmpty ).toBe( false ); + + await Page.open( "/" ); + const url = await browser.getUrl(); + + expect( url ).not.toContain( 'Special:WikibaseDashboard' ); + } ); + + it( 'Should update the table to show the number of entities added', async function () { + await Page.open( '/wiki/Special:WikibaseDashboard' ); + + const startingTableItemCount = await $( '#wbs-dashboard-item-count' ).getText(); + await WikibaseApi.createItem( 'dashboard-redirect-test' ); + const updatedTableItemCount = await $( '#wbs-dashboard-item-count' ).getText(); + expect( Number(updatedTableItemCount) ).toEqual( Number(startingTableItemCount) + 1 ); + + const startingTablePropertyCount = await $( '#wbs-dashboard-property-count' ).getText(); + await WikibaseApi.createProperty( 'string', { 'foo': 'bar' } ); + const updatedTablePropertyCount = await $( '#wbs-dashboard-property-count' ).getText(); + expect( Number(updatedTablePropertyCount) ).toEqual( Number(startingTablePropertyCount) + 1 ); + } ); +} ); \ No newline at end of file diff --git a/docs/operate/migration-guides/wbs-3-to-4.md b/docs/operate/migration-guides/wbs-3-to-4.md index 9fd9ba031..fd7070666 100644 --- a/docs/operate/migration-guides/wbs-3-to-4.md +++ b/docs/operate/migration-guides/wbs-3-to-4.md @@ -74,3 +74,8 @@ This guide explains how to upgrade Wikibase Suite (WBS) from version 3 to 4 whil docker compose up -d docker compose ps ``` + +7. Recreate the Query Service index. WBS 4 updates the Query Service from + version 1 to version 2, changing the index format. Follow the [Query Service + data reload guidance](../../../development/images/wdqs/README.md#upgrading) + to reindex the service.