Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ $assetsDetailed = $parser->getAssetsDetailed();
$orphanAssets = $parser->getOrphanAssets();
$missingAssets = $parser->getMissingAssets();
$brokenReferences = $parser->getBrokenReferences();
$internalLinks = $parser->getInternalLinks();
$brokenInternalLinks = $parser->getBrokenInternalLinks();
$manifest = $parser->getPackageManifest();
$metadata = $parser->getMetadata();
$userPreferences = $parser->getUserPreferences();
$odeResources = $parser->getOdeResources();
Expand Down Expand Up @@ -140,7 +143,7 @@ if (!$result['valid']) {
print_r($result['warnings']);
```

The validator reports unresolved assets, duplicate identifiers, broken page-parent relationships, hierarchy cycles, relationship/order inconsistencies and missing v4 baseline files/directories.
The validator reports unresolved assets, broken internal `exe-node:` links, duplicate identifiers, broken page-parent relationships, hierarchy cycles, relationship/order inconsistencies, missing iDevice runtime directories and missing v4 baseline files/directories.

Schema validation is optional and only uses a caller-supplied trusted local schema:

Expand Down
6 changes: 6 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ Read core format/version/project metadata without fully normalizing pages, iDevi
- `getMissingAssets(): array`
- `getBrokenReferences(): array`
- `getArchiveEntries(): array`
- `getInternalLinks(): array`
- `getBrokenInternalLinks(): array`
- `getUsedIdeviceTypes(): array`
- `getAvailableIdeviceTypes(): array`
- `getMissingIdeviceRuntimes(): array`
- `getPackageManifest(): array`

Asset references are returned only when they resolve to an entry in the project archive. Both the v3 long form (`{{context_path}}/content/resources/...`) and the v4 form (`{{context_path}}/<exportPath>`) are resolved.

Expand Down
163 changes: 163 additions & 0 deletions src/ELPParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Exelearning\Model\Project;
use Exelearning\Parser\LegacyParser;
use Exelearning\Parser\OdeParser;
use Exelearning\Reference\InternalReferenceExtractor;
use Exelearning\Support\ProjectInspector;
use Exelearning\Support\VersionDetector;
use Exelearning\Support\XmlLoader;
Expand Down Expand Up @@ -97,6 +98,7 @@ class ELPParser implements JsonSerializable
private ArchiveLimits $archiveLimits;
private ArchiveReader $archiveReader;
private AssetReferenceExtractor $assetExtractor;
private InternalReferenceExtractor $internalReferenceExtractor;

/**
* Create a new parser instance.
Expand Down Expand Up @@ -198,6 +200,7 @@ protected function parse(): void
}

$this->assetExtractor = new AssetReferenceExtractor($this->archiveEntries);
$this->internalReferenceExtractor = new InternalReferenceExtractor();
$this->assetsDetailed = $this->assetExtractor->extract($this->pages);
$this->assets = array_map(
static fn(array $asset): string => (string) $asset['path'],
Expand Down Expand Up @@ -980,6 +983,163 @@ public function getArchiveEntries(): array
return $this->archiveEntries;
}

/**
* Get internal exe-node page references with their origins.
*
* @return array<int, array<string, mixed>>
*/
public function getInternalLinks(): array
{
return $this->internalReferenceExtractor->extract($this->pages);
}

/**
* Get internal page references whose target does not exist.
*
* @return array<int, array<string, mixed>>
*/
public function getBrokenInternalLinks(): array
{
$pageIds = [];

foreach ($this->pages as $page) {
$id = (string) ($page['id'] ?? '');
if ($id !== '') {
$pageIds[$id] = true;
}
}

return array_values(
array_filter(
$this->getInternalLinks(),
static fn(array $link): bool => !isset(
$pageIds[(string) ($link['targetPageId'] ?? '')]
)
)
);
}

/**
* Get the iDevice types used by parsed content.
*
* @return array<int, string>
*/
public function getUsedIdeviceTypes(): array
{
$types = [];

foreach ($this->getIdevices() as $idevice) {
$type = (string) ($idevice['type'] ?? '');
if ($type !== '') {
$types[$type] = true;
}
}

$types = array_keys($types);
sort($types);

return $types;
}

/**
* Get iDevice runtime directories available in the package.
*
* @return array<int, string>
*/
public function getAvailableIdeviceTypes(): array
{
$types = [];

foreach ($this->archiveEntries as $entry) {
if (preg_match('#^idevices/([^/]+)/#', $entry, $matches) === 1) {
$types[(string) $matches[1]] = true;
}
}

$types = array_keys($types);
sort($types);

return $types;
}

/**
* Get used iDevice types without a matching packaged runtime directory.
*
* @return array<int, string>
*/
public function getMissingIdeviceRuntimes(): array
{
return array_values(
array_diff(
$this->getUsedIdeviceTypes(),
$this->getAvailableIdeviceTypes()
)
);
}

/**
* Build a categorized manifest of package entries.
*
* @return array<string, mixed>
*/
public function getPackageManifest(): array
{
$manifest = [
'rootFiles' => [],
'themeFiles' => [],
'libraryFiles' => [],
'ideviceFiles' => [],
'resourceFiles' => [],
'otherFiles' => [],
];

foreach ($this->archiveEntries as $entry) {
if (str_ends_with($entry, '/')) {
continue;
}

if (!str_contains($entry, '/')) {
$manifest['rootFiles'][] = $entry;
continue;
}

if (str_starts_with($entry, 'theme/')) {
$manifest['themeFiles'][] = $entry;
continue;
}

if (str_starts_with($entry, 'libs/')) {
$manifest['libraryFiles'][] = $entry;
continue;
}

if (preg_match('#^idevices/([^/]+)/#', $entry, $matches) === 1) {
$type = (string) $matches[1];
$manifest['ideviceFiles'][$type][] = $entry;
continue;
}

if (str_starts_with($entry, 'content/resources/')) {
$manifest['resourceFiles'][] = $entry;
continue;
}

$manifest['otherFiles'][] = $entry;
}

foreach (['rootFiles', 'themeFiles', 'libraryFiles', 'resourceFiles', 'otherFiles'] as $key) {
sort($manifest[$key]);
}

ksort($manifest['ideviceFiles']);

return $manifest + [
'usedIdeviceTypes' => $this->getUsedIdeviceTypes(),
'availableIdeviceTypes' => $this->getAvailableIdeviceTypes(),
'missingIdeviceRuntimes' => $this->getMissingIdeviceRuntimes(),
];
}

/**
* Get the project title.
*
Expand Down Expand Up @@ -1101,6 +1261,9 @@ public function toDetailedArray(): array
'orphanAssets' => $this->getOrphanAssets(),
'missingAssets' => $this->getMissingAssets(),
'brokenReferences' => $this->getBrokenReferences(),
'internalLinks' => $this->getInternalLinks(),
'brokenInternalLinks' => $this->getBrokenInternalLinks(),
'packageManifest' => $this->getPackageManifest(),
'archiveEntries' => $this->archiveEntries,
];
}
Expand Down
132 changes: 132 additions & 0 deletions src/Reference/InternalReferenceExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

/**
* InternalReferenceExtractor.php
*
* PHP Version 8.0
*
* @category Parser
* @package Exelearning
* @author INTEF <cedec@educacion.gob.es>
* @license MIT https://opensource.org/licenses/MIT
* @link https://github.com/exelearning/elp-parser
*/

namespace Exelearning\Reference;

/**
* Extract internal exe-node page references from normalized project content.
*/
final class InternalReferenceExtractor
{
private const INTERNAL_LINK_PATTERN = '/exe-node:([A-Za-z0-9_-]+)/';

/**
* Extract internal page references with their page/iDevice origins.
*
* @param array<int, array<string, mixed>> $pages Parsed pages.
*
* @return array<int, array<string, mixed>>
*/
public function extract(array $pages): array
{
$links = [];

foreach ($pages as $page) {
foreach (($page['idevices'] ?? []) as $idevice) {
if (!is_array($idevice)) {
continue;
}

$sources = [];
$html = (string) ($idevice['html'] ?? '');
if ($html !== '') {
$sources[] = $html;
}

$this->collectStringValues($idevice['jsonProperties'] ?? [], $sources);

if (($idevice['storagePattern'] ?? '') !== 'standard-json') {
$this->collectStringValues($idevice['data'] ?? [], $sources);
}

foreach ($sources as $source) {
foreach ($this->extractTargets($source) as $targetPageId) {
$key = (string) ($page['id'] ?? '')
. '|'
. (string) ($idevice['id'] ?? '')
. '|'
. $targetPageId;

$links[$key] ??= [
'targetPageId' => $targetPageId,
'pageId' => $page['id'] ?? '',
'pageTitle' => $page['title'] ?? '',
'ideviceId' => $idevice['id'] ?? '',
'ideviceType' => $idevice['type'] ?? '',
'occurrences' => 0,
];

$links[$key]['occurrences']++;
}
}
}
}

return array_values($links);
}

/**
* Extract unique target page IDs from text.
*
* @param string $source Arbitrary HTML/JSON text.
*
* @return array<int, string>
*/
private function extractTargets(string $source): array
{
if ($source === '') {
return [];
}

$decoded = html_entity_decode(
rawurldecode($source),
ENT_QUOTES | ENT_HTML5,
'UTF-8'
);

preg_match_all(self::INTERNAL_LINK_PATTERN, $decoded, $matches);

return array_values(
array_unique(
array_filter(
array_map('strval', $matches[1] ?? [])
)
)
);
}

/**
* Recursively collect string values from structured state.
*
* @param mixed $value Value to inspect.
* @param array<int, string> $strings Collected strings.
*
* @return void
*/
private function collectStringValues(mixed $value, array &$strings): void
{
if (is_string($value)) {
$strings[] = $value;
return;
}

if (!is_array($value)) {
return;
}

foreach ($value as $nested) {
$this->collectStringValues($nested, $strings);
}
}
}
Loading
Loading