diff --git a/extension.json b/extension.json index 0997aff..d683382 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "KnowledgeGraph", - "version": "2.2.0", + "version": "3.0.0-beta", "author": "Thomas-topway-it for [https://knowledge.wiki KM-A]", "url": "https://github.com/SemanticMediaWiki/KnowledgeGraph", "descriptionmsg": "knowledge-graph-desc", @@ -104,6 +104,8 @@ "knowledgegraph-dialog-results-no-properties", "knowledgegraph-dialog-results-no-articles", "knowledgegraph-dialog-results-existing-node", + "knowledgegraph-dialog-results-skipped-existing", + "knowledgegraph-dialog-results-no-new-nodes", "knowledgegraph-dialog-results-has-properties", "knowledgegraph-dialog-results-importing-nodes", "knowledgegraph-copied-to-clipboard", diff --git a/i18n/de.json b/i18n/de.json index 32ad6f1..cf53ff3 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -39,6 +39,8 @@ "knowledgegraph-dialog-results-no-properties": "keine Attribute", "knowledgegraph-dialog-results-no-articles": "keine Seiten", "knowledgegraph-dialog-results-existing-node": "existierender Knoten", + "knowledgegraph-dialog-results-skipped-existing": "die folgenden Knoten existieren bereits und wurden übersprungen:", + "knowledgegraph-dialog-results-no-new-nodes": "Keine neuen Knoten zum Hinzufügen", "knowledgegraph-dialog-results-has-properties": "hat Attribute:", "knowledgegraph-dialog-results-importing-nodes": "importiert Knoten:", "knowledgegraph-copied-to-clipboard": "In die Zwischenablage kopiert!", diff --git a/i18n/en.json b/i18n/en.json index 83291f5..a96a3bf 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -39,6 +39,8 @@ "knowledgegraph-dialog-results-no-properties": "No properties", "knowledgegraph-dialog-results-no-articles": "No articles", "knowledgegraph-dialog-results-existing-node": "Existing node", + "knowledgegraph-dialog-results-skipped-existing": "The following nodes already exist and were skipped:", + "knowledgegraph-dialog-results-no-new-nodes": "No new nodes to add.", "knowledgegraph-dialog-results-has-properties": "Has properties:", "knowledgegraph-dialog-results-importing-nodes": "Importing nodes:", "knowledgegraph-copied-to-clipboard": "copied to clipboard!", diff --git a/includes/KnowledgeGraph.php b/includes/KnowledgeGraph.php index e86fabd..c1ca188 100644 --- a/includes/KnowledgeGraph.php +++ b/includes/KnowledgeGraph.php @@ -15,6 +15,13 @@ class KnowledgeGraph { + /** + * Tracks seen relations to prevent duplicate processing. + * + * @var array + */ + private static $relationsSeen = []; + /** * Configuration options for Semantic MediaWiki. * @@ -235,18 +242,11 @@ public static function parserFunctionKnowledgeGraph( Parser $parser, ...$argv ) } } - $visited = []; foreach ( $params['nodes'] as $titleText ) { $title_ = TitleClass::newFromText( $titleText ); if ( $title_ && $title_->isKnown() ) { if ( !isset( self::$data[$title_->getFullText()] ) ) { - self::setSemanticDataForParserFunction( - $title_, - $params['properties'], - 0, - $params['depth'], - $visited - ); + self::setSemanticDataFromApi( $title_, $params['properties'], 0, $params['depth'] ); } } } @@ -514,863 +514,180 @@ public static function articlesInCategories( $category, $limit, $offset ) { } /** - * Retrieves semantic data for the given page title, used within the Knowledge Graph Designer context. - * Unlike the parser function version, this method includes additional metadata (such as 'context') - * to support frontend logic specific to the visual graph designer. - * * @see https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/PageProperties/+/refs/heads/1.0.3/includes/PageProperties.php * @param Title|MediaWiki\Title\Title $title * @param array $onlyProperties * @param int $depth * @param int $maxDepth - * @param array &$visited * @return array */ - public static function setSemanticDataForDesigner( - Title $title, - $onlyProperties, - $depth, - $maxDepth, - array &$visited = [] - ) { - $services = MediaWikiServices::getInstance(); - $langCode = \RequestContext::getMain()->getLanguage()->getCode(); - $propertyRegistry = \SMW\PropertyRegistry::getInstance(); - $dataTypeRegistry = \SMW\DataTypeRegistry::getInstance(); + public static function setSemanticDataFromApi( Title $title, $onlyProperties, $depth, $maxDepth ) { + $titleText = $title->getFullText(); - $wikiPage = self::getWikiPage( $title ); - $fullTitleText = $title->getFullText(); - if ( !in_array( $fullTitleText, $visited, true ) ) { - $visited[] = $fullTitleText; + if ( isset( self::$data[$titleText] ) ) { + return; } - $categories = []; - $iterator = $wikiPage->getCategories(); - - while ( $iterator->valid() ) { - $text_ = $iterator->current()->getText(); - $categories[] = $text_; - $iterator->next(); + if ( $depth > $maxDepth ) { + return; } - $output = [ + self::$data[$titleText] = [ 'properties' => [], - 'categories' => $categories + 'categories' => [], ]; - if ( $title->getNamespace() === NS_FILE ) { - $img = $services->getRepoGroup()->findFile( $title ); - if ( $img ) { - $output['src'] = $img->getFullUrl(); - } - } - - // ***important, this prevents infinite recursion - // no properties - self::$data[$title->getFullText()] = []; - - $subject = new \SMW\DIWikiPage( $title->getDbKey(), $title->getNamespace() ); - $semanticData = self::$SMWStore->getSemanticData( $subject ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - if ( in_array( $key, self::$exclude ) ) { - continue; - } - - $propertyDv = self::$SMWDataValueFactory->newDataValueByItem( $property, null ); - if ( !$property->isUserAnnotable() || !$propertyDv->isVisible() ) { - continue; - } - - $canonicalLabel = $property->getCanonicalLabel(); - $preferredLabel = $property->getPreferredLabel(); - - if ( count( $onlyProperties ) - && !in_array( $canonicalLabel, $onlyProperties ) - && !in_array( $preferredLabel, $onlyProperties ) - ) { - $onlyProperties[] = $canonicalLabel; - } - - $description = $propertyRegistry->findPropertyDescriptionMsgKeyById( $key ); - $typeID = $property->findPropertyTypeID(); - - if ( $description ) { - $description = wfMessage( $description )->text(); - } - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( empty( $typeLabel ) ) { - $typeId_ = $dataTypeRegistry->getFieldType( $typeID ); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeId_ ); - } - - $propertyTitle = $property->getCanonicalDiWikiPage()->getTitle(); - $objKey = $propertyTitle->getFullText(); - - $output['properties'][$objKey] = [ - // 'url' => $propertyTitle->getFullURL(), - 'key' => $key, - 'typeId' => $typeID, - 'canonicalLabel' => $canonicalLabel, - 'preferredLabel' => $preferredLabel, - 'typeLabel' => $typeLabel, - 'description' => $description, - 'values' => [] - ]; - - foreach ( $semanticData->getPropertyValues( $property ) as $dataItem ) { - $dataValue = self::$SMWDataValueFactory->newDataValueByItem( $dataItem, $property ); - if ( $dataValue->isValid() ) { - // *** are they necessary ? - $dataValue->setOption( 'no.text.transformation', true ); - $dataValue->setOption( 'form/short', true ); - - $obj_ = []; - if ( $typeID === '_wpg' ) { - $title_ = $dataItem->getTitle(); - if ( $title_ && $title_->isKnown() ) { - if ( !isset( self::$data[$title_->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForDesigner( - $title_, - $onlyProperties, - ++$depth, - $maxDepth, - $visited - ); - } else { - // not loaded - self::$data[$title_->getFullText()] = null; - } - } - $obj_['value'] = $title_->getFullText(); - - if ( $title_->getNamespace() === NS_FILE ) { - $img_ = $services->getRepoGroup()->findFile( $title_ ); - if ( $img_ ) { - $obj_['src'] = $img_->getFullUrl(); - } - } - } elseif ( !isset( self::$data[str_replace( '_', ' ', $dataValue->getWikiValue() )] ) ) { - $obj_['value'] = str_replace( '_', ' ', $dataValue->getWikiValue() ); - } - } else { - $obj_['value'] = $dataValue->getWikiValue(); - } + $apiParams = [ + 'action' => 'smwbrowse', + 'format' => 'json', + 'browse' => 'subject', + 'params' => json_encode( [ + 'subject' => $titleText, + 'ns' => $title->getNamespace(), + ] ), + ]; - $output['properties'][$objKey]['values'][] = $obj_; - } + $request = new \FauxRequest( $apiParams, false ); + $api = new \ApiMain( $request ); + $api->execute(); + $result = $api->getResult()->getResultData(); - $propertyDI = \SMW\DIProperty::newFromUserLabel( $canonicalLabel ); - $results = self::getSubjectsByProperty( - $propertyDI, - $limit, - 0, - $fullTitleText - ); - - foreach ( $results as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - - $subject = new \SMW\DIWikiPage( $sourceTitle->getDbKey(), $sourceTitle->getNamespace() ); - $semanticData = self::$SMWStore->getSemanticData( $subject ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - if ( str_replace( '_', ' ', $key ) === $propertyLabel ) { - $typeID = $property->findPropertyTypeID(); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( $sourceTitle && !in_array( $sourceTitle->getFullText(), $visited, true ) ) { - self::addInversePropertyToOutput( - $canonicalLabel, - $sourceTitle, - $preferredLabel, - $typeID, - $typeLabel, - $output - ); - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForDesigner( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - } - } - } - } - } - } + if ( isset( $result['error'] ) ) { + wfDebugLog( 'SemanticData', 'SMW API error: ' . json_encode( $result['error'] ) ); + return; } - $resultsToCheck = []; - if ( count( $onlyProperties ) > 0 ) { - $countProps = 0; - foreach ( $onlyProperties as $property ) { - $propertyLabel = str_replace( '_', ' ', $property ); - $propertyDI = \SMW\DIProperty::newFromUserLabel( $propertyLabel ); - $results = self::getSubjectsByProperty( - $propertyDI, - $limit, - 0, - $fullTitleText - ); - $countProps++; - $countResults = 0; - - if ( count( $results ) === 0 ) { - $visitedTitle = []; - foreach ( $resultsToCheck as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - - if ( in_array( $sourceTitle->getFullText(), $visitedTitle, true ) ) { - continue; - } - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForDesigner( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - - $visitedTitle[] = $sourceTitle->getFullText(); - } - } - } + $data = $result['query']['data'] ?? []; + $output = &self::$data[$titleText]; - foreach ( $results as $subjectDI ) { - $resultsToCheck[] = $subjectDI; - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - $countResults++; - - $subject = new \SMW\DIWikiPage( $sourceTitle->getDbKey(), $sourceTitle->getNamespace() ); - $semanticData = self::$SMWStore->getSemanticData( $subject ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - - if ( str_replace( '_', ' ', $key ) === $propertyLabel ) { - $typeID = $property->findPropertyTypeID(); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( $sourceTitle && !in_array( $sourceTitle->getFullText(), $visited, true ) ) { - self::addInversePropertyToOutput( - $propertyLabel, - $sourceTitle, - "", - $typeID, - $typeLabel, - $output - ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - if ( in_array( $key, self::$exclude ) ) { - continue; - } - - $propertyDv = self::$SMWDataValueFactory->newDataValueByItem( $property, null ); - if ( !$property->isUserAnnotable() || !$propertyDv->isVisible() ) { - continue; - } - - $typeID = $property->findPropertyTypeID(); - - if ( $typeID === '_wpg' ) { - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( empty( $typeLabel ) ) { - $typeId_ = $dataTypeRegistry->getFieldType( $typeID ); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeId_ ); - } - - $canonicalName = MediaWikiServices::getInstance() - ->getNamespaceInfo() - ->getCanonicalName( SMW_NS_PROPERTY ); - - $inverseKey = $canonicalName . ':' . $propertyLabel; - - $output['properties'][$inverseKey]['typeLabel'] = $typeLabel; - } - } - - if ( count( $onlyProperties ) === $countProps ) { - $visitedTitle = []; - foreach ( $resultsToCheck as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - - if ( in_array( $sourceTitle->getFullText(), $visitedTitle, true ) ) { - continue; - } - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForDesigner( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - - $visitedTitle[] = $sourceTitle->getFullText(); - } - } - } - } - - if ( $sourceTitle ) { - self::addInversePropertyToOutput( - $propertyLabel, - $sourceTitle, - "", - $typeID, - $typeLabel, - $output - ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - if ( in_array( $key, self::$exclude ) ) { - continue; - } - - $propertyDv = self::$SMWDataValueFactory->newDataValueByItem( $property, null ); - if ( !$property->isUserAnnotable() || !$propertyDv->isVisible() ) { - continue; - } - - $typeID = $property->findPropertyTypeID(); - - if ( $typeID === '_wpg' ) { - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( empty( $typeLabel ) ) { - $typeId_ = $dataTypeRegistry->getFieldType( $typeID ); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeId_ ); - } - - $canonicalName = MediaWikiServices::getInstance() - ->getNamespaceInfo() - ->getCanonicalName( SMW_NS_PROPERTY ); - - $inverseKey = $canonicalName . ':' . $propertyLabel; - - $output['properties'][$inverseKey]['typeLabel'] = $typeLabel; - } - } - - if ( count( $onlyProperties ) === $countProps ) { - $visitedTitle = []; - foreach ( $resultsToCheck as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - - if ( in_array( $sourceTitle->getFullText(), $visitedTitle, true ) ) { - continue; - } - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForDesigner( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - - $visitedTitle[] = $sourceTitle->getFullText(); - } - } - } - } - } - } - } + if ( $title->getNamespace() === NS_FILE ) { + $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); + if ( $file ) { + $output['src'] = $file->getFullUrl(); } } - $output['context'] = 'KnowledgeGraphDesigner'; - self::$data[$title->getFullText()] = $output; - } - - /** - * Retrieves semantic data for the given page title, used within a parser function context. - * Supports recursive traversal of linked pages up to a defined depth. - * - * @see https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/PageProperties/+/refs/heads/1.0.3/includes/PageProperties.php - * @param Title|MediaWiki\Title\Title $title - * @param array $onlyProperties - * @param int $depth - * @param int $maxDepth - * @param array &$visited - * @return array - */ - public static function setSemanticDataForParserFunction( - Title $title, - $onlyProperties, - $depth, - $maxDepth, - array &$visited = [] - ) { - $services = MediaWikiServices::getInstance(); - $langCode = \RequestContext::getMain()->getLanguage()->getCode(); $propertyRegistry = \SMW\PropertyRegistry::getInstance(); $dataTypeRegistry = \SMW\DataTypeRegistry::getInstance(); + $pendingRecursiveTitles = []; - $wikiPage = self::getWikiPage( $title ); - $fullTitleText = $title->getFullText(); - if ( !in_array( $fullTitleText, $visited, true ) ) { - $visited[] = $fullTitleText; - } - - $categories = []; - $iterator = $wikiPage->getCategories(); - - while ( $iterator->valid() ) { - $text_ = $iterator->current()->getText(); - $categories[] = $text_; - $iterator->next(); - - // if ( !array_key_exists( $text_, self::$categories ) ) { - // self::$categories[$text_] = []; - // } - - // if ( !in_array( $title->getFullText(), self::$categories[$text_] ) ) { - // self::$categories[$text_][] = $title->getFullText(); - // } - } - - $output = [ - 'properties' => [], - 'categories' => $categories - ]; - - if ( $title->getNamespace() === NS_FILE ) { - $img = $services->getRepoGroup()->findFile( $title ); - if ( $img ) { - $output['src'] = $img->getFullUrl(); - } - } - - // ***important, this prevents infinite recursion - // no properties - self::$data[$title->getFullText()] = []; - - $subject = new \SMW\DIWikiPage( $title->getDbKey(), $title->getNamespace() ); - $semanticData = self::$SMWStore->getSemanticData( $subject ); - $preferredLabel = ''; - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - if ( in_array( $key, self::$exclude ) ) { + foreach ( $data as $entry ) { + $direction = $entry['direction'] ?? 'direct'; + $keyRaw = $entry['property'] ?? null; + $key = $keyRaw ? str_replace( '_', ' ', $keyRaw ) : null; + if ( !$key ) { continue; } - $propertyDv = self::$SMWDataValueFactory->newDataValueByItem( $property, null ); - if ( !$property->isUserAnnotable() || !$propertyDv->isVisible() ) { - continue; - } + $isInverse = $direction === 'inverse'; + $propKey = $isInverse ? '-' . $key : $key; - $canonicalLabel = $property->getCanonicalLabel(); - $preferredLabel = $property->getPreferredLabel(); + if ( count( $onlyProperties ) ) { + $allowed = in_array( $propKey, $onlyProperties ) + || in_array( $key, $onlyProperties ); - $description = $propertyRegistry->findPropertyDescriptionMsgKeyById( $key ); - $typeID = $property->findPropertyTypeID(); - - if ( count( $onlyProperties ) - && !in_array( $canonicalLabel, $onlyProperties ) - && !in_array( $preferredLabel, $onlyProperties ) - ) { - continue; - } + if ( $isInverse && !in_array( $propKey, $onlyProperties ) ) { + continue; + } - if ( $description ) { - $description = wfMessage( $description )->text(); + if ( !$allowed ) { + continue; + } } - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - if ( empty( $typeLabel ) ) { - $typeId_ = $dataTypeRegistry->getFieldType( $typeID ); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeId_ ); - } + if ( !isset( $output['properties'][$propKey] ) ) { + $propertyTitle = \Title::newFromText( ltrim( $propKey, '-' ) ); - $propertyTitle = $property->getCanonicalDiWikiPage()->getTitle(); - $objKey = $propertyTitle->getFullText(); - - $output['properties'][$objKey] = [ - // 'url' => $propertyTitle->getFullURL(), - 'key' => $key, - 'typeId' => $typeID, - 'canonicalLabel' => $canonicalLabel, - 'preferredLabel' => $preferredLabel, - 'typeLabel' => $typeLabel, - 'description' => $description, - 'isInverse' => false, - 'values' => [], - ]; - - foreach ( $semanticData->getPropertyValues( $property ) as $dataItem ) { - $dataValue = self::$SMWDataValueFactory->newDataValueByItem( $dataItem, $property ); - if ( $dataValue->isValid() ) { - // *** are they necessary ? - $dataValue->setOption( 'no.text.transformation', true ); - $dataValue->setOption( 'form/short', true ); - - $obj_ = []; - $title_ = null; - if ( $typeID === '_wpg' ) { - $title_ = $dataItem->getTitle(); - if ( $title_ && $title_->isKnown() ) { - if ( !isset( self::$data[$title_->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - $obj_['direction'] = 'direct'; - self::setSemanticDataForParserFunction( - $title_, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - // not loaded - self::$data[$title_->getFullText()] = null; - } - } - - $obj_['value'] = $title_->getFullText(); - - if ( $title_->getNamespace() === NS_FILE ) { - $img_ = $services->getRepoGroup()->findFile( $title_ ); - if ( $img_ ) { - $obj_['src'] = $img_->getFullUrl(); - } - } - } elseif ( !isset( self::$data[str_replace( '_', ' ', $dataValue->getWikiValue() )] ) ) { - $obj_['direction'] = 'direct'; - $obj_['value'] = str_replace( '_', ' ', $dataValue->getWikiValue() ); - } + if ( $propertyTitle ) { + $diProperty = \SMW\DIProperty::newFromUserLabel( $propKey ); + if ( $diProperty ) { + $typeID = $diProperty->findPropertyTypeID(); + $canonicalLabel = $diProperty->getCanonicalLabel(); + $preferredLabel = $diProperty->getPreferredLabel(); + $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); + $descriptionKey = $propertyRegistry->findPropertyDescriptionMsgKeyById( $diProperty->getKey() ); + $description = $descriptionKey ? wfMessage( $descriptionKey )->text() : null; + + $output['properties'][$propKey] = [ + 'key' => $propKey, + 'typeId' => $typeID, + 'canonicalLabel' => $canonicalLabel, + 'preferredLabel' => $preferredLabel, + 'typeLabel' => $typeLabel, + 'description' => $description, + 'inverse' => $isInverse, + 'values' => [], + ]; } else { - $obj_['direction'] = 'direct'; - $obj_['value'] = $dataValue->getWikiValue(); + $output['properties'][$propKey] = [ + 'key' => $propKey, + 'values' => [], + ]; } - - self::processInverseProperties( - $typeID, - $typeLabel, - $onlyProperties, - $fullTitleText, - $preferredLabel, - $depth, - $maxDepth, - $visited, - $output - ); - - $output['properties'][$objKey]['values'][] = $obj_; + } else { + $output['properties'][$propKey] = [ + 'key' => $propKey, + 'values' => [], + ]; } } - } - self::processInverseProperties( - $typeID, - $typeLabel, - $onlyProperties, - $fullTitleText, - $preferredLabel, - $depth, - $maxDepth, - $visited, - $output - ); - - self::$data[$title->getFullText()] = $output; - } + foreach ( $entry['dataitem'] ?? [] as $item ) { + if ( $item['type'] === 9 ) { + $linkedTitle = explode( '#', $item['item'] )[0]; + $linkedTitle = $linkedTitle ? str_replace( '_', ' ', $linkedTitle ) : null; + if ( !$linkedTitle ) { + continue; + } - /** - * Processes inverse semantic properties for entities of type `_wpg`. - * - * This method identifies and processes all properties from the `$onlyProperties` list that are marked as - * inverse (i.e., start with a `-` prefix). For each such property, it retrieves all subjects that reference - * the current entity (`$fullTitleText`) using that property. - * - * The results are added to the `$output` array, marking them as inverse relationships. If the depth limit - * (`$maxDepth`) has not been reached, the method recursively processes each related subject to build a - * deeper semantic graph. - * - * @param string|null $typeID The type ID of the entity; must be `_wpg` or null to proceed. - * @param string|null $typeLabel The label associated with the type, used for display purposes. - * @param array $onlyProperties A list of property labels to filter by; inverse properties start with `-`. - * @param string $fullTitleText The full title text of the current page, used to match incoming references. - * @param string $preferredLabel The display label to associate with the found inverse properties. - * @param int $depth The current recursion depth. - * @param int $maxDepth The maximum allowed recursion depth. - * @param array &$visited A list of already visited page titles to prevent infinite loops. - * @param array &$output The data structure where inverse property results will be added. - */ - private static function processInverseProperties( - ?string $typeID, - ?string $typeLabel, - array $onlyProperties, - string $fullTitleText, - string $preferredLabel, - int $depth, - int $maxDepth, - array &$visited, - array &$output - ): void { - $dataTypeRegistry = \SMW\DataTypeRegistry::getInstance(); - if ( ( $typeID !== '_wpg' && $typeID !== null ) || count( $onlyProperties ) === 0 ) { - - if ( $typeID === '_txt' ) { - $inverseProps = array_filter( $onlyProperties, static function ( $property ) { - return strpos( $property, '-' ) === 0; - } ); - - foreach ( $inverseProps as $inversePropertyLabel ) { - $cleanLabel = ltrim( $inversePropertyLabel, '-' ); - $propertyDI = \SMW\DIProperty::newFromUserLabel( $cleanLabel ); - - $results = self::getSubjectsByProperty( - $propertyDI, - $limit, - 0, - $fullTitleText - ); - - $nodes = []; - foreach ( $results as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - if ( !in_array( $sourceTitle->getFullText(), $nodes, true ) ) { - $nodes[] = $sourceTitle->getFullText(); - } + $source = $titleText; + $target = $linkedTitle; + $relation = ltrim( $propKey, '-' ); + $relKey = self::makeRelationKey( $source, $target, $relation ); + + if ( isset( self::$relationsSeen[$relKey] ) ) { + continue; } + self::$relationsSeen[$relKey] = true; + + $output['properties'][$propKey]['values'][] = [ 'value' => $linkedTitle ]; - foreach ( $results as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - - $subject = new \SMW\DIWikiPage( $sourceTitle->getDbKey(), $sourceTitle->getNamespace() ); - $semanticData = self::$SMWStore->getSemanticData( $subject ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - - if ( str_replace( '_', ' ', $key ) === $cleanLabel ) { - $typeID = $property->findPropertyTypeID(); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( $sourceTitle && !in_array( $sourceTitle->getFullText(), $visited, true ) ) { - self::addInversePropertyToOutput( - $cleanLabel, - $sourceTitle, - $preferredLabel, - $typeID, - $typeLabel, - $output - ); - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForParserFunction( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - } - } - } - } + if ( $depth < $maxDepth && !isset( self::$data[$linkedTitle] ) ) { + $pendingRecursiveTitles[] = $linkedTitle; } + } else { + $output['properties'][$propKey]['values'][] = [ + 'value' => $item['item'], + 'type' => $item['type'], + ]; } } } - $typeID = '_wpg'; - - $inverseProps = array_filter( $onlyProperties, static function ( $property ) { - return strpos( $property, '-' ) === 0; - } ); - - foreach ( $inverseProps as $inversePropertyLabel ) { - $cleanLabel = ltrim( $inversePropertyLabel, '-' ); - $propertyDI = \SMW\DIProperty::newFromUserLabel( $cleanLabel ); - - $results = self::getSubjectsByProperty( - $propertyDI, - $limit, - 0, - $fullTitleText - ); - - $nodes = []; - foreach ( $results as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - if ( !in_array( $sourceTitle->getFullText(), $nodes, true ) ) { - $nodes[] = $sourceTitle->getFullText(); - } + $page = self::getWikiPage( $title ); + if ( $page ) { + $iterator = $page->getCategories(); + while ( $iterator->valid() ) { + $output['categories'][] = $iterator->current()->getText(); + $iterator->next(); } + } - foreach ( $results as $subjectDI ) { - $sourceTitle = Title::newFromText( - $subjectDI->getDBkey(), - $subjectDI->getNamespace() - ); - - $subject = new \SMW\DIWikiPage( $sourceTitle->getDbKey(), $sourceTitle->getNamespace() ); - $semanticData = self::$SMWStore->getSemanticData( $subject ); - - foreach ( $semanticData->getProperties() as $property ) { - $key = $property->getKey(); - - if ( str_replace( '_', ' ', $key ) === $cleanLabel ) { - $typeID = $property->findPropertyTypeID(); - $typeLabel = $dataTypeRegistry->findTypeLabel( $typeID ); - - if ( $sourceTitle && !in_array( $sourceTitle->getFullText(), $visited, true ) ) { - self::addInversePropertyToOutput( - $cleanLabel, - $sourceTitle, - $preferredLabel, - $typeID, - $typeLabel, - $output - ); - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForParserFunction( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - } - } - - if ( $sourceTitle ) { - self::addInversePropertyToOutput( - $cleanLabel, - $sourceTitle, - $preferredLabel, - $typeID, - $typeLabel, - $output - ); - - if ( !isset( self::$data[$sourceTitle->getFullText()] ) ) { - if ( $depth < $maxDepth ) { - self::setSemanticDataForParserFunction( - $sourceTitle, - $onlyProperties, - $depth + 1, - $maxDepth, - $visited - ); - } else { - self::$data[$sourceTitle->getFullText()] = null; - } - } - } - } - } + foreach ( $pendingRecursiveTitles as $linkedTitle ) { + $title_ = \Title::newFromText( $linkedTitle ); + if ( $title_ && $title_->isKnown() ) { + self::setSemanticDataFromApi( $title_, $onlyProperties, $depth + 1, $maxDepth ); } } } - /** - * Adds an inverse property entry to the output array. - * - * This method populates the `$output['properties']` structure with details about an inverse property - * relationship, including the source page that references the current entity. The property is marked - * as inverse and is associated with labels and type information. - * - * @param string $cleanLabel The user-defined label of the property, with the '-' prefix removed. - * @param Title $sourceTitle The MediaWiki title object of the page that links to the current entity. - * @param string $preferredLabel The label to use for display purposes. - * @param string $typeID The type ID of the property, usually `_wpg`. - * @param string|null $typeLabel The label associated with the type, used for display purposes. - * @param array &$output The data structure where the inverse property information is stored. - */ - private static function addInversePropertyToOutput( - string $cleanLabel, - Title $sourceTitle, - string $preferredLabel, - string $typeID, - ?string $typeLabel, - array &$output - ): void { - $canonicalName = MediaWikiServices::getInstance() - ->getNamespaceInfo() - ->getCanonicalName( SMW_NS_PROPERTY ); - - $inverseKey = $canonicalName . ':' . $cleanLabel; - - $obj_inv = [ - 'direction' => 'inverse', - 'value' => $sourceTitle->getFullText(), - ]; + private static function makeRelationKey( string $a, string $b, string $prop ): string { + $sorted = [ $a, $b ]; + sort( $sorted, SORT_STRING ); + return $sorted[0] . '::' . $prop . '::' . $sorted[1]; + } - $output['properties'][$inverseKey]['values'][] = $obj_inv; - $output['properties'][$inverseKey]['canonicalLabel'] = $cleanLabel; - $output['properties'][$inverseKey]['preferredLabel'] = $preferredLabel; - $output['properties'][$inverseKey]['typeId'] = $typeID; - $output['properties'][$inverseKey]['typeLabel'] = $typeLabel; - $output['properties'][$inverseKey]['isInverse'] = true; + public static function resetSeenRelations(): void { + self::$relationsSeen = []; } } diff --git a/includes/api/KnowledgeGraphApiLoadCategories.php b/includes/api/KnowledgeGraphApiLoadCategories.php index fc7aa04..f7f66ee 100644 --- a/includes/api/KnowledgeGraphApiLoadCategories.php +++ b/includes/api/KnowledgeGraphApiLoadCategories.php @@ -199,7 +199,7 @@ public function execute() { if ( $title_ && $title_->isKnown() ) { if ( !isset( self::$data[$title_->getFullText()] ) ) { - \KnowledgeGraph::setSemanticDataForParserFunction( + \KnowledgeGraph::setSemanticDataFromApi( $title_, $params['properties'], 0, diff --git a/includes/api/KnowledgeGraphApiLoadNodes.php b/includes/api/KnowledgeGraphApiLoadNodes.php index 9cc1767..c3c846d 100644 --- a/includes/api/KnowledgeGraphApiLoadNodes.php +++ b/includes/api/KnowledgeGraphApiLoadNodes.php @@ -171,9 +171,20 @@ public function execute() { } $params['properties'] = array_unique( $params['properties'] ); + + $params['properties'] = array_unique( + array_merge( + $params['properties'], + array_map( + fn ( $prop ) => '-' . $prop, + $params['properties'] + ) + ) + ); + if ( $title_ && $title_->isKnown() ) { if ( !isset( self::$data[$title_->getFullText()] ) ) { - \KnowledgeGraph::setSemanticDataForDesigner( $title_, $params['properties'], 0, $params['depth'] ); + \KnowledgeGraph::setSemanticDataFromApi( $title_, $params['properties'], 0, $params['depth'] ); } } } diff --git a/includes/api/KnowledgeGraphApiLoadProperties.php b/includes/api/KnowledgeGraphApiLoadProperties.php index 28e91e5..a9374e8 100644 --- a/includes/api/KnowledgeGraphApiLoadProperties.php +++ b/includes/api/KnowledgeGraphApiLoadProperties.php @@ -48,7 +48,7 @@ public function execute() { $title_ = TitleClass::newFromText( $titleText ); if ( $title_ && $title_->isKnown() ) { if ( !isset( self::$data[$title_->getFullText()] ) ) { - \KnowledgeGraph::setSemanticDataForParserFunction( + \KnowledgeGraph::setSemanticDataFromApi( $title_, $params['properties'], 0, diff --git a/resources/KnowledgeGraph.css b/resources/KnowledgeGraph.css index f4c9d04..1fc6cd9 100644 --- a/resources/KnowledgeGraph.css +++ b/resources/KnowledgeGraph.css @@ -63,4 +63,9 @@ padding: 4px; } - +.custom-menu li.custom-menu-link-entry:hover, +.custom-menu li.custom-menu-property-entry:hover, +.custom-menu li.custom-menu-edge-entry:hover { + background-color: #e0f0ff; + cursor: pointer; +} diff --git a/resources/KnowledgeGraph.js b/resources/KnowledgeGraph.js index 0a85b12..8e7e2eb 100644 --- a/resources/KnowledgeGraph.js +++ b/resources/KnowledgeGraph.js @@ -27,6 +27,7 @@ KnowledgeGraph = function () { var Categories = {}; var LegendDiv; var PropIdPropLabelMap = {}; + var nodePropertiesCache = {}; function addLegendEntry(id, label, color) { if ($(LegendDiv).find('#' + id.replace(/ /g, '_')).length) { @@ -130,6 +131,9 @@ KnowledgeGraph = function () { properties: JSON.stringify(Config['properties']), }; } else if (obj.properties !== null) { + if (obj.properties === undefined) { + obj.properties = []; + } var payload = { action: 'knowledgegraph-load-properties', properties: obj.properties.join('|'), @@ -149,15 +153,12 @@ KnowledgeGraph = function () { }; } - // console.log('payload', payload); - return new Promise((resolve, reject) => { mw.loader.using('mediawiki.api', function () { new mw.Api() .postWithToken('csrf', payload) .done(function (thisRes) { if ('data' in thisRes[payload.action]) { - // console.log('data', data); var data_ = JSON.parse(thisRes[payload.action].data); resolve(data_); } else { @@ -209,22 +210,25 @@ KnowledgeGraph = function () { return panel.$element.get(0); } - function addArticleNode(data, label, options) { + function addArticleNode(data, label, options, typeID) { if (Nodes.get(label) !== null) { return; } + let cleanLabel = label.split('#')[0]; + var nodeConfig = jQuery.extend( JSON.parse(JSON.stringify(Config.graphOptions.nodes)), label in Config.propertyOptions ? Config.propertyOptions[label] : {}, { id: label, label: - label.length <= maxPropValueLength - ? label - : label.substring(0, maxPropValueLength) + '…', + cleanLabel.length <= maxPropValueLength + ? cleanLabel + : cleanLabel.substring(0, maxPropValueLength) + '…', shape: 'box', font: { size: 30 }, + typeID: typeID || 9, // https://visjs.github.io/vis-network/examples/network/other/popups.html // title: createHTMLTitle(label), @@ -257,12 +261,19 @@ KnowledgeGraph = function () { function createNodes(data) { for (var label in data) { - if (label in Data && Data[label] !== null) continue; + if (label in Data && Data[label] !== null) { + continue; + } addArticleNode(data, label); - if (data[label] === null) continue; - if (!(label in Categories)) Categories[label] = []; + if (data[label] === null) { + continue; + } + + if (!(label in Categories)) { + Categories[label] = []; + } for (var i in data[label].categories) { var category = data[label].categories[i]; @@ -272,14 +283,15 @@ KnowledgeGraph = function () { } for (var i in data[label].properties) { - let seenValues = new Set(); var property = data[label].properties[i]; if (!(property.canonicalLabel in PropColors)) { - let color_; + var color_; function colorExists() { for (var j in PropColors) { - if (PropColors[j] === color_) return true; + if (PropColors[j] === color_) { + return true; + } } return false; } @@ -289,7 +301,7 @@ KnowledgeGraph = function () { PropColors[property.canonicalLabel] = color_; } - let options = + var options = property.preferredLabel in Config.propertyOptions ? Config.propertyOptions[property.preferredLabel] : property.canonicalLabel in Config.propertyOptions @@ -303,110 +315,113 @@ KnowledgeGraph = function () { options.color = PropColors[property.canonicalLabel]; } - let legendLabel = property.preferredLabel !== '' ? property.preferredLabel : property.canonicalLabel; + var legendLabel = + property.preferredLabel !== '' + ? property.preferredLabel + : property.canonicalLabel; + if (!(legendLabel in PropIdPropLabelMap)) { PropIdPropLabelMap[legendLabel] = []; } - let propLabel = legendLabel + (!Config['show-property-type'] ? '' : ' (' + property.typeLabel + ')'); + + var propLabel = + legendLabel + + (!Config['show-property-type'] + ? '' + : ' (' + property.typeLabel + ')'); if (Config['properties-panel']) { - addLegendEntry(property.canonicalLabel, legendLabel, PropColors[property.canonicalLabel]); + addLegendEntry( + property.canonicalLabel, + legendLabel, + PropColors[property.canonicalLabel] + ); } - + switch (property.typeId) { case '_wpg': - for (let value of property.values) { - if (seenValues.has(value.value)) continue; - seenValues.add(value.value); + for (var ii in property.values) { + var targetLabel = property.values[ii].value; + PropIdPropLabelMap[legendLabel].push(targetLabel); - PropIdPropLabelMap[legendLabel].push(value.value); - let edgeConfig = jQuery.extend( - JSON.parse(JSON.stringify(Config.graphOptions.edges)), - value.direction === 'inverse' - ? { - from: value.value, - to: label, - label: (value.direction === 'inverse' ? '-' : '') + propLabel, - group: label, - } - : { - from: label, - to: value.value, - label: propLabel, - group: label, - } - ); + var from = property.inverse ? targetLabel : label; + var to = property.inverse ? label : targetLabel; - let exists = false; - Edges.forEach((edge) => { - const labelsMatch = edge.label === edgeConfig.label || edge.label === '-' + edgeConfig.label || '-' + edge.label === edgeConfig.label; - const sameDirection = edge.from === edgeConfig.from && edge.to === edgeConfig.to; - const oppositeDirection = edge.from === edgeConfig.to && edge.to === edgeConfig.from; + let edgeId = KnowledgeGraphFunctions.makeEdgeId(from, to, property.canonicalLabel, 9, Nodes); - if (labelsMatch && (sameDirection || oppositeDirection)) { - exists = true; + var edgeConfig = jQuery.extend( + JSON.parse(JSON.stringify(Config.graphOptions.edges)), + { + id: edgeId, + from: from, + to: to, + label: propLabel, + group: label, + arrows: { + to: { enabled: true } + } } - }); - - edgeConfig.arrows.to.enabled = true; + ); - if (!exists) { - Edges.add(edgeConfig); - } + // Edges.add(edgeConfig); + graphModel.addEdge(edgeConfig); - if (value.src && mw.config.get('KnowledgeGraphShowImages') === true) { + if ( + property.values[ii].src && + mw.config.get('KnowledgeGraphShowImages') === true + ) { options.shape = 'image'; - options.image = value.src; + options.image = property.values[ii].src; } - addArticleNode(data, value.value, options); + addArticleNode(data, targetLabel, options, 9); } break; default: - let filteredValues; - // separate logic for KnowledgeGraphDesigner and parserfunction - if (data[label]?.context === 'KnowledgeGraphDesigner') { - filteredValues = property.values.filter(v => !seenValues.has(v.value)); - } else { - filteredValues = property.values.filter(v => 'direction' in v && !seenValues.has(v.value)); - } - - if (filteredValues.length === 0) break; - - for (let val of filteredValues) seenValues.add(val.value); - - let valueId = `${i}#${KnowledgeGraphFunctions.uuidv4()}`; - PropIdPropLabelMap[legendLabel].push(valueId); - - Edges.add({ - from: label, - to: valueId, - label: propLabel, - group: label, - }); - - let propValue = filteredValues.map((x) => x.value).join(', '); + const seen = new Set(); + for (const { value: targetLabel } of property.values) { + if (seen.has(targetLabel)) continue; + seen.add(targetLabel); + + const typeId = property.typeId === '_txt' ? 2 : property.typeId; + const valueId = KnowledgeGraphFunctions.makeNodeId(targetLabel, typeId); + const edgeLabel = property.canonicalLabel || propLabel; + + PropIdPropLabelMap[legendLabel].push(valueId); + + const edgeId = KnowledgeGraphFunctions.makeEdgeId(label, valueId, edgeLabel); + Edges.add({ + id: edgeId, + from: label, + to: valueId, + label: propLabel, + group: label, + }); - Nodes.add( - jQuery.extend(options, { - id: valueId, - label: propValue.length <= maxPropValueLength - ? propValue - : propValue.substring(0, maxPropValueLength) + '…', - }) - ); + if (!Nodes.get(valueId)) { + const displayLabel = targetLabel.length <= maxPropValueLength + ? targetLabel + : targetLabel.substring(0, maxPropValueLength) + '…'; + + Nodes.add( + jQuery.extend({}, options, { + id: valueId, + label: displayLabel, + typeID: typeId, + }) + ); + } + } + } } } - } - Data = jQuery.extend(Data, data); } function HideNodesRec(nodeId) { var children = Network.getConnectedNodes(nodeId); // children = children.filter((x) => excludedIds.indexOf(x) === -1); - // console.log('children', children); var updateNodes = []; for (var nodeId_ of children) { if (!(nodeId_ in Data)) { @@ -473,15 +488,34 @@ KnowledgeGraph = function () { properties = thisDialog.propertiesInputWidget.getValue(); titles = thisDialog.titlesInputWidget.getValue(); - if (!titles.length) { + if (!titles.length || !properties.length) { resolve(); return; } - if (!properties.length) { + const existingTitles = []; + const newTitles = []; + + for (let i = 0; i < titles.length; i++) { + const titleObj = mw.Title.newFromText( titles[i] ); + if (!titleObj) continue; + + const fullTitle = titleObj.getPrefixedText(); + if (fullTitle in Data) { + existingTitles.push( fullTitle ); + } else { + newTitles.push( fullTitle ); + } + } + + if (newTitles.length === 0) { + thisDialog.actions.setMode('existing-node'); + thisDialog.initializeResultsPanel('existing-node'); resolve(); return; } + thisDialog._titlesToProcess = newTitles; + thisDialog._skippedTitles = existingTitles; depth = thisDialog.depthInputWidgetProperties.getValue(); limit = thisDialog.limitInputWidgetProperties.getValue(); offset = thisDialog.offsetInputWidgetProperties.getValue(); @@ -499,8 +533,6 @@ KnowledgeGraph = function () { limit = thisDialog.limitInputWidgetCategories.getValue(); offset = thisDialog.offsetInputWidgetCategories.getValue(); break; - - // console.log('properties', properties); } loadNodes({ @@ -512,7 +544,6 @@ KnowledgeGraph = function () { offset: parseInt(offset), }) .then(function (data) { - // console.log('data', data); // Properties = data[titleFullText]; TmpData = data; if (selectedTab === 'by-article') { @@ -591,69 +622,76 @@ KnowledgeGraph = function () { '' ); var properties = data[titleFullText].properties; - Object.keys(properties).forEach(function (k1) { - var prop1 = properties[k1]; - - if (prop1.typeLabel) return; - - Object.keys(properties).forEach(function (k2) { - if (k1 === k2) return; - - var prop2 = properties[k2]; - if ( - prop2.canonicalLabel === prop1.canonicalLabel && - prop2.typeLabel && - (!prop1.typeLabel || prop1.typeLabel === '') - ) { - prop1.typeLabel = prop2.typeLabel; - } - }); - }); - for (var i in properties) { - var prop = properties[i]; var url = mw.config.get('wgArticlePath').replace('$1', i); - var hasDirect = false; - var hasInverse = false; - - if (Array.isArray(prop.values)) { - for (var j = 0; j < prop.values.length; j++) { - var val = prop.values[j]; - if (val.hasOwnProperty('direction') && val.direction === 'inverse') { - hasInverse = true; - } else { - hasDirect = true; - } - } - } + $el.append( + $( + '
  • ' + + (properties[i].preferredLabel !== '' + ? properties[i].preferredLabel + : properties[i].canonicalLabel) + + ' (' + + properties[i].typeLabel + + ')' + + '
  • ' + ) + ); + } + break; - if (hasDirect) { - var labelDirect = - (prop.preferredLabel !== '' - ? prop.preferredLabel - : prop.canonicalLabel) + ' (' + prop.typeLabel + ')'; + case 'by-properties': + // mw.msg + if (Object.keys(data).some((i) => !(i in Data) && data[i] !== null)) { + thisDialog.panelB.$element.append( + '

    ' + mw.msg('knowledgegraph-dialog-results-importing-nodes') + '

    ' + ); - $el.append( - $('
  • ' + labelDirect + '
  • ') - ); + var $newList = $('