| Total Complexity | 215 |
| Total Lines | 1310 |
| Duplicated Lines | 0 % |
| Changes | 1 | ||
| Bugs | 0 | Features | 0 |
Complex classes like Cc13Export often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use Cc13Export, and based on these observations, apply Extract Interface, too.
| 1 | <?php |
||
| 45 | class Cc13Export |
||
| 46 | { |
||
| 47 | /** |
||
| 48 | * Legacy course container (DTO with resources[...]). |
||
| 49 | */ |
||
| 50 | private object $course; |
||
| 51 | |||
| 52 | private bool $selectionMode; |
||
| 53 | private bool $debug; |
||
| 54 | |||
| 55 | /** |
||
| 56 | * Working directory on disk. |
||
| 57 | */ |
||
| 58 | private string $workdir = ''; |
||
| 59 | |||
| 60 | /** |
||
| 61 | * Absolute path to the resulting .imscc file. |
||
| 62 | */ |
||
| 63 | private string $packagePath = ''; |
||
| 64 | |||
| 65 | /** |
||
| 66 | * Doctrine & repositories. |
||
| 67 | */ |
||
| 68 | private EntityManagerInterface $em; |
||
| 69 | private CDocumentRepository $docRepo; |
||
| 70 | private ResourceNodeRepository $rnRepo; |
||
| 71 | |||
| 72 | /** |
||
| 73 | * Project base dir (for var/upload/resource). |
||
| 74 | */ |
||
| 75 | private string $projectDir = ''; |
||
| 76 | |||
| 77 | /** |
||
| 78 | * Cached CourseEntity for the current course code. |
||
| 79 | */ |
||
| 80 | private ?CourseEntity $courseEntity = null; |
||
| 81 | |||
| 82 | /** |
||
| 83 | * Course code kept for legacy FS fallback. |
||
| 84 | */ |
||
| 85 | private string $courseCodeForLegacy = ''; |
||
| 86 | |||
| 87 | /** |
||
| 88 | * Path to debug log file (in system temp dir). |
||
| 89 | */ |
||
| 90 | private string $logFile = ''; |
||
| 91 | |||
| 92 | /** |
||
| 93 | * CC 1.3 namespaces. |
||
| 94 | */ |
||
| 95 | private array $ns = [ |
||
| 96 | 'imscc' => 'http://www.imsglobal.org/xsd/imsccv1p3/imscp_v1p1', |
||
| 97 | 'lomimscc' => 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/manifest', |
||
| 98 | 'lom' => 'http://ltsc.ieee.org/xsd/imsccv1p3/LOM/resource', |
||
| 99 | 'xsi' => 'http://www.w3.org/2001/XMLSchema-instance', |
||
| 100 | ]; |
||
| 101 | |||
| 102 | /** |
||
| 103 | * schemaLocation map (optional). |
||
| 104 | */ |
||
| 105 | private array $schemaLocations = [ |
||
| 106 | 'imscc' => 'http://www.imsglobal.org/profile/cc/ccv1p3/ccv1p3_imscp_v1p2_v1p0.xsd', |
||
| 107 | 'lomimscc' => 'http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lommanifest_v1p0.xsd', |
||
| 108 | 'lom' => 'http://www.imsglobal.org/profile/cc/ccv1p3/LOM/ccv1p3_lomresource_v1p0.xsd', |
||
| 109 | ]; |
||
| 110 | |||
| 111 | public function __construct(object $course, bool $selectionMode = false, bool $debug = false) |
||
| 112 | { |
||
| 113 | $this->course = $course; |
||
| 114 | $this->selectionMode = $selectionMode; |
||
| 115 | $this->debug = $debug; |
||
| 116 | |||
| 117 | // Resolve services safely (throw if missing) |
||
| 118 | $this->em = Database::getManager(); |
||
| 119 | |||
| 120 | $docRepo = Container::getDocumentRepository(); |
||
| 121 | if (!$docRepo instanceof CDocumentRepository) { |
||
|
|
|||
| 122 | throw new RuntimeException('CDocumentRepository service not available'); |
||
| 123 | } |
||
| 124 | $this->docRepo = $docRepo; |
||
| 125 | |||
| 126 | $rnRepoRaw = Container::$container->get(ResourceNodeRepository::class); |
||
| 127 | if (!$rnRepoRaw instanceof ResourceNodeRepository) { |
||
| 128 | throw new RuntimeException('ResourceNodeRepository service not available'); |
||
| 129 | } |
||
| 130 | $this->rnRepo = $rnRepoRaw; |
||
| 131 | |||
| 132 | $kernel = Container::$container->get('kernel'); |
||
| 133 | if (!$kernel instanceof KernelInterface) { |
||
| 134 | throw new RuntimeException('Kernel service not available'); |
||
| 135 | } |
||
| 136 | $this->projectDir = rtrim($kernel->getProjectDir(), '/'); |
||
| 137 | |||
| 138 | // Prepare log file in system temp dir |
||
| 139 | $this->logFile = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'cc13_export.log'; |
||
| 140 | if ($this->debug) { |
||
| 141 | $this->log('Exporter constructed', [ |
||
| 142 | 'class_file' => (new ReflectionClass(self::class))->getFileName(), |
||
| 143 | 'projectDir' => $this->projectDir, |
||
| 144 | 'tempDir' => sys_get_temp_dir(), |
||
| 145 | ]); |
||
| 146 | } |
||
| 147 | } |
||
| 148 | |||
| 149 | /** |
||
| 150 | * Build the .imscc package and return its absolute path. |
||
| 151 | * |
||
| 152 | * @param string $courseCode current course code (api_get_course_id()) |
||
| 153 | * @param string|null $exportDir optional subdir name; defaults to "cc13_YYYYmmdd_His" |
||
| 154 | */ |
||
| 155 | public function export(string $courseCode, ?string $exportDir = null): string |
||
| 156 | { |
||
| 157 | $this->log('Export start', ['courseCode' => $courseCode]); |
||
| 158 | |||
| 159 | $this->courseEntity = $this->em->getRepository(CourseEntity::class)->findOneBy(['code' => $courseCode]); |
||
| 160 | if (!$this->courseEntity instanceof CourseEntity) { |
||
| 161 | $this->log('Course not found', ['courseCode' => $courseCode], 'error'); |
||
| 162 | |||
| 163 | throw new RuntimeException('Course not found for code: '.$courseCode); |
||
| 164 | } |
||
| 165 | |||
| 166 | $this->courseCodeForLegacy = (string) $this->courseEntity->getCode(); |
||
| 167 | |||
| 168 | $exportDir = $exportDir ?: ('cc13_'.date('Ymd_His')); |
||
| 169 | $baseTmp = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); |
||
| 170 | $this->workdir = $baseTmp.DIRECTORY_SEPARATOR.$exportDir; |
||
| 171 | |||
| 172 | $this->log('Workdir prepare', ['workdir' => $this->workdir]); |
||
| 173 | |||
| 174 | // Prepare working dir |
||
| 175 | $this->rrmdir($this->workdir); |
||
| 176 | $this->mkdirp($this->workdir.'/resources'); |
||
| 177 | $this->mkdirp($this->workdir.'/_generated'); // for inline-content exports |
||
| 178 | |||
| 179 | // Environment checks |
||
| 180 | $this->checkEnvironment(); |
||
| 181 | |||
| 182 | // Collect candidate resources |
||
| 183 | $docList = $this->collectDocuments(); |
||
| 184 | $this->mkdirp($this->workdir.'/weblinks'); |
||
| 185 | $wlList = $this->collectWebLinks(); |
||
| 186 | $this->mkdirp($this->workdir.'/discussions'); |
||
| 187 | $dtList = $this->collectDiscussionTopics(); |
||
| 188 | |||
| 189 | $this->log('Collected resources', [ |
||
| 190 | 'documents' => \count($docList), |
||
| 191 | 'weblinks' => \count($wlList), |
||
| 192 | 'discussions' => \count($dtList), |
||
| 193 | ]); |
||
| 194 | |||
| 195 | // Materialize into package and build resource entries |
||
| 196 | $resourceEntries = []; |
||
| 197 | $resourceEntries = array_merge( |
||
| 198 | $resourceEntries, |
||
| 199 | $this->copyDocumentsIntoPackage($docList) |
||
| 200 | ); |
||
| 201 | $resourceEntries = array_merge( |
||
| 202 | $resourceEntries, |
||
| 203 | $this->writeWebLinksIntoPackage($wlList) |
||
| 204 | ); |
||
| 205 | $resourceEntries = array_merge( |
||
| 206 | $resourceEntries, |
||
| 207 | $this->writeDiscussionTopicsIntoPackage($dtList) |
||
| 208 | ); |
||
| 209 | |||
| 210 | // CHANGED: allow export as long as there is at least ONE resource of any supported type. |
||
| 211 | if (empty($resourceEntries)) { |
||
| 212 | $this->log('Nothing to export (no CC-compatible resources)', [], 'warn'); |
||
| 213 | |||
| 214 | throw new RuntimeException('Nothing to export (no CC 1.3-compatible resources: documents, links or discussions).'); |
||
| 215 | } |
||
| 216 | |||
| 217 | // Write imsmanifest.xml |
||
| 218 | $this->writeManifest($resourceEntries); |
||
| 219 | $this->log('imsmanifest.xml written', ['resourceCount' => \count($resourceEntries)]); |
||
| 220 | |||
| 221 | // Zip → .imscc |
||
| 222 | $filename = \sprintf('%s_cc13_%s.imscc', $this->courseCodeForLegacy, date('Ymd_His')); |
||
| 223 | $this->packagePath = $this->normalizePath($this->workdir.'/../'.$filename); |
||
| 224 | |||
| 225 | $this->zipDir($this->workdir, $this->packagePath); |
||
| 226 | $this->log('Package zipped', ['packagePath' => $this->packagePath]); |
||
| 227 | |||
| 228 | // Cleanup temp working dir |
||
| 229 | $this->rrmdir($this->workdir); |
||
| 230 | $this->log('Workdir cleaned up', ['workdir' => $this->workdir]); |
||
| 231 | |||
| 232 | $this->log('Export done', ['packagePath' => $this->packagePath]); |
||
| 233 | |||
| 234 | return $this->packagePath; |
||
| 235 | } |
||
| 236 | |||
| 237 | /** |
||
| 238 | * Write imsmanifest.xml: |
||
| 239 | * - <manifest> root with minimal LOM metadata |
||
| 240 | * - <organizations> containing a hierarchical <organization> |
||
| 241 | * - <resources> with entries for webcontent, weblink, and discussion topic |
||
| 242 | * |
||
| 243 | * @param array<int,array{identifier:string, href:string, type:string, title?:string, path?:string}> $resources |
||
| 244 | */ |
||
| 245 | private function writeManifest(array $resources): void |
||
| 246 | { |
||
| 247 | $doc = new DOMDocument('1.0', 'UTF-8'); |
||
| 248 | $doc->formatOutput = true; |
||
| 249 | |||
| 250 | $imscc = $this->ns['imscc']; |
||
| 251 | |||
| 252 | // Root <manifest> |
||
| 253 | $manifest = $doc->createElementNS($imscc, 'manifest'); |
||
| 254 | $manifest->setAttribute('identifier', 'MANIFEST-'.bin2hex(random_bytes(6))); |
||
| 255 | // Helpful schema attributes (harmless if ignored) |
||
| 256 | $manifest->setAttribute('schema', 'IMS Common Cartridge'); |
||
| 257 | $manifest->setAttribute('schemaversion', '1.3.0'); |
||
| 258 | |||
| 259 | // Namespace declarations |
||
| 260 | foreach ($this->ns as $prefix => $uri) { |
||
| 261 | $manifest->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:'.$prefix, $uri); |
||
| 262 | } |
||
| 263 | |||
| 264 | // xsi:schemaLocation (optional) |
||
| 265 | $schemaLocation = ''; |
||
| 266 | foreach ($this->schemaLocations as $k => $loc) { |
||
| 267 | if (!isset($this->ns[$k])) { |
||
| 268 | continue; |
||
| 269 | } |
||
| 270 | $schemaLocation .= ($schemaLocation ? ' ' : '').$this->ns[$k].' '.$loc; |
||
| 271 | } |
||
| 272 | if ($schemaLocation) { |
||
| 273 | $manifest->setAttributeNS($this->ns['xsi'], 'xsi:schemaLocation', $schemaLocation); |
||
| 274 | } |
||
| 275 | |||
| 276 | // Optional LOM metadata (very small payload) |
||
| 277 | $metadata = $doc->createElementNS($imscc, 'metadata'); |
||
| 278 | $lom = $doc->createElementNS($this->ns['lomimscc'], 'lom'); |
||
| 279 | $general = $doc->createElementNS($this->ns['lomimscc'], 'general'); |
||
| 280 | $title = $doc->createElementNS($this->ns['lomimscc'], 'title'); |
||
| 281 | $titleStr = $doc->createElementNS($this->ns['lomimscc'], 'string'); |
||
| 282 | $courseTitle = ''; |
||
| 283 | |||
| 284 | try { |
||
| 285 | if ($this->courseEntity instanceof CourseEntity) { |
||
| 286 | if (method_exists($this->courseEntity, 'getTitle')) { |
||
| 287 | $courseTitle = (string) $this->courseEntity->getTitle(); |
||
| 288 | } |
||
| 289 | if ('' === $courseTitle && method_exists($this->courseEntity, 'getName')) { |
||
| 290 | $courseTitle = (string) $this->courseEntity->getName(); |
||
| 291 | } |
||
| 292 | } |
||
| 293 | } catch (Throwable) { |
||
| 294 | // swallow |
||
| 295 | } |
||
| 296 | if ('' === $courseTitle) { |
||
| 297 | $courseTitle = (string) ($this->courseCodeForLegacy ?: 'Course package'); |
||
| 298 | } |
||
| 299 | $titleStr->nodeValue = $courseTitle; |
||
| 300 | $title->appendChild($titleStr); |
||
| 301 | $general->appendChild($title); |
||
| 302 | |||
| 303 | $lang = $doc->createElementNS($this->ns['lomimscc'], 'language'); |
||
| 304 | $lang->nodeValue = 'en'; |
||
| 305 | $general->appendChild($lang); |
||
| 306 | |||
| 307 | $lom->appendChild($general); |
||
| 308 | $metadata->appendChild($lom); |
||
| 309 | $manifest->appendChild($metadata); |
||
| 310 | |||
| 311 | // <organizations> (single org with hierarchical items) |
||
| 312 | $orgs = $doc->createElementNS($imscc, 'organizations'); |
||
| 313 | $orgId = 'ORG-'.bin2hex(random_bytes(4)); |
||
| 314 | $orgs->setAttribute('default', $orgId); |
||
| 315 | $org = $doc->createElementNS($imscc, 'organization'); |
||
| 316 | $org->setAttribute('identifier', $orgId); |
||
| 317 | $org->setAttribute('structure', 'hierarchical'); |
||
| 318 | |||
| 319 | // Build <item> tree based on 'path' (preferred) or href-derived path |
||
| 320 | $folderNodeByPath = []; |
||
| 321 | $getOrCreateFolder = function (array $parts) use ($doc, $imscc, $org, &$folderNodeByPath): DOMElement { |
||
| 322 | $acc = ''; |
||
| 323 | $parent = $org; |
||
| 324 | foreach ($parts as $seg) { |
||
| 325 | if ('' === $seg) { |
||
| 326 | continue; |
||
| 327 | } |
||
| 328 | $acc = ('' === $acc) ? $seg : ($acc.'/'.$seg); |
||
| 329 | if (!isset($folderNodeByPath[$acc])) { |
||
| 330 | $item = $doc->createElementNS($imscc, 'item'); |
||
| 331 | $item->setAttribute('identifier', 'ITEM-'.substr(sha1($acc), 0, 12)); |
||
| 332 | $titleEl = $doc->createElementNS($imscc, 'title'); |
||
| 333 | $titleEl->nodeValue = $seg; |
||
| 334 | $item->appendChild($titleEl); |
||
| 335 | $parent->appendChild($item); |
||
| 336 | $folderNodeByPath[$acc] = $item; |
||
| 337 | } |
||
| 338 | $parent = $folderNodeByPath[$acc]; |
||
| 339 | } |
||
| 340 | |||
| 341 | return $parent; |
||
| 342 | }; |
||
| 343 | |||
| 344 | foreach ($resources as $r) { |
||
| 345 | $href = (string) ($r['href'] ?? ''); |
||
| 346 | if ('' === $href) { |
||
| 347 | continue; |
||
| 348 | } |
||
| 349 | |||
| 350 | // Prefer explicit 'path' (e.g., "Web Links/Title.xml" or "Discussions/Title.xml") |
||
| 351 | if (!empty($r['path'])) { |
||
| 352 | $relForTree = trim((string) $r['path'], '/'); |
||
| 353 | } else { |
||
| 354 | $relForTree = preg_replace('#^resources/#', '', $href) ?? $href; |
||
| 355 | $relForTree = ltrim($relForTree, '/'); |
||
| 356 | } |
||
| 357 | |||
| 358 | $parts = array_values(array_filter(explode('/', $relForTree), static fn ($s) => '' !== $s)); |
||
| 359 | if (empty($parts)) { |
||
| 360 | continue; |
||
| 361 | } |
||
| 362 | |||
| 363 | $fileName = array_pop($parts); |
||
| 364 | $folderParent = $getOrCreateFolder($parts); |
||
| 365 | |||
| 366 | $item = $doc->createElementNS($imscc, 'item'); |
||
| 367 | $item->setAttribute('identifier', 'ITEM-'.substr(sha1($href.($r['identifier'] ?? '')), 0, 12)); |
||
| 368 | $item->setAttribute('identifierref', $r['identifier']); |
||
| 369 | |||
| 370 | $t = $doc->createElementNS($imscc, 'title'); |
||
| 371 | $title = (string) ($r['title'] ?? $fileName); |
||
| 372 | $t->nodeValue = '' !== $title ? $title : $fileName; |
||
| 373 | $item->appendChild($t); |
||
| 374 | |||
| 375 | $folderParent->appendChild($item); |
||
| 376 | } |
||
| 377 | |||
| 378 | $orgs->appendChild($org); |
||
| 379 | $manifest->appendChild($orgs); |
||
| 380 | |||
| 381 | // <resources> |
||
| 382 | $resNode = $doc->createElementNS($imscc, 'resources'); |
||
| 383 | foreach ($resources as $r) { |
||
| 384 | $res = $doc->createElementNS($imscc, 'resource'); |
||
| 385 | $res->setAttribute('identifier', $r['identifier']); |
||
| 386 | $res->setAttribute('type', $r['type']); |
||
| 387 | $res->setAttribute('href', $r['href']); |
||
| 388 | |||
| 389 | $file = $doc->createElementNS($imscc, 'file'); |
||
| 390 | $file->setAttribute('href', $r['href']); |
||
| 391 | $res->appendChild($file); |
||
| 392 | |||
| 393 | $resNode->appendChild($res); |
||
| 394 | } |
||
| 395 | $manifest->appendChild($resNode); |
||
| 396 | |||
| 397 | $doc->appendChild($manifest); |
||
| 398 | |||
| 399 | $path = $this->workdir.'/imsmanifest.xml'; |
||
| 400 | if (false === file_put_contents($path, $doc->saveXML() ?: '')) { |
||
| 401 | $this->log('Failed to write imsmanifest.xml', ['path' => $path], 'error'); |
||
| 402 | |||
| 403 | throw new RuntimeException('Failed to write imsmanifest.xml'); |
||
| 404 | } |
||
| 405 | } |
||
| 406 | |||
| 407 | /** |
||
| 408 | * Collect documents from the legacy bag. |
||
| 409 | * For files, resolve absolute filesystem path via multi-step strategy. |
||
| 410 | * |
||
| 411 | * @return array<int,array{src:string, relZip:string, is_dir:bool, debug?:array}> |
||
| 412 | */ |
||
| 413 | private function collectDocuments(): array |
||
| 414 | { |
||
| 415 | $docs = []; |
||
| 416 | |||
| 417 | $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; |
||
| 418 | $docKey = $this->firstExistingKey( |
||
| 419 | $res, |
||
| 420 | ['document', 'Document', \defined('RESOURCE_DOCUMENT') ? (string) RESOURCE_DOCUMENT : ''] |
||
| 421 | ); |
||
| 422 | |||
| 423 | if (!$docKey || empty($res[$docKey]) || !\is_array($res[$docKey])) { |
||
| 424 | $this->log('No "document" bucket found in course bag', [], 'warn'); |
||
| 425 | |||
| 426 | return $docs; |
||
| 427 | } |
||
| 428 | |||
| 429 | $this->log('Scanning document bucket', ['items' => \count($res[$docKey])]); |
||
| 430 | |||
| 431 | foreach ($res[$docKey] as $id => $wrap) { |
||
| 432 | if (!\is_object($wrap)) { |
||
| 433 | continue; |
||
| 434 | } |
||
| 435 | |||
| 436 | $entity = $this->unwrap($wrap); |
||
| 437 | $attempts = []; // per-item trace |
||
| 438 | |||
| 439 | // Path (tolerant) |
||
| 440 | $raw = (string) ($entity->path ?? $entity->full_path ?? ''); |
||
| 441 | if ('' === $raw) { |
||
| 442 | $raw = '/document/'.((string) ($entity->title ?? '')); |
||
| 443 | } |
||
| 444 | |||
| 445 | $segments = $this->extractDocumentSegmentsFromPath($raw); |
||
| 446 | if (empty($segments)) { |
||
| 447 | $this->log('Skipping non-document path', ['raw' => $raw, 'id' => (string) $id], 'debug'); |
||
| 448 | |||
| 449 | continue; |
||
| 450 | } |
||
| 451 | |||
| 452 | $fileType = strtolower((string) ($entity->file_type ?? $entity->filetype ?? '')); |
||
| 453 | $isDir = ('folder' === $fileType) || str_ends_with($raw, '/'); |
||
| 454 | |||
| 455 | $relDoc = implode('/', $segments); |
||
| 456 | $relZip = 'resources/'.$relDoc; |
||
| 457 | |||
| 458 | if ($isDir) { |
||
| 459 | $docs[] = ['src' => '', 'relZip' => rtrim($relZip, '/').'/', 'is_dir' => true, 'debug' => ['note' => 'folder']]; |
||
| 460 | $this->log('Folder queued', ['rel' => $relZip], 'debug'); |
||
| 461 | |||
| 462 | continue; |
||
| 463 | } |
||
| 464 | |||
| 465 | // Resolution pipeline |
||
| 466 | $abs = null; |
||
| 467 | |||
| 468 | $abs = $this->resolveByWrapperSourceId($wrap, $attempts); |
||
| 469 | if (!$abs) { |
||
| 470 | $abs = $this->resolveByEntityResourceNodeBestFile($entity, $attempts); |
||
| 471 | } |
||
| 472 | if (!$abs) { |
||
| 473 | $abs = $this->resolveAbsoluteForDocumentSegments($segments, $attempts); |
||
| 474 | } |
||
| 475 | if (!$abs) { |
||
| 476 | $abs = $this->resolveLegacyFilesystemBySegments($segments, $attempts); |
||
| 477 | } |
||
| 478 | $generatedAbs = null; |
||
| 479 | if (!$abs) { |
||
| 480 | $generatedAbs = $this->generateFromInlineContentIfAny($entity, $relDoc, $attempts); |
||
| 481 | if ($generatedAbs) { |
||
| 482 | $abs = $generatedAbs; |
||
| 483 | } |
||
| 484 | } |
||
| 485 | |||
| 486 | if (!$abs) { |
||
| 487 | $this->log('Missing file after pipeline', [ |
||
| 488 | 'raw' => $raw, |
||
| 489 | 'iid' => (int) ($entity->iid ?? $entity->id ?? 0), |
||
| 490 | 'attempts' => $attempts, |
||
| 491 | ], 'warn'); |
||
| 492 | |||
| 493 | $docs[] = [ |
||
| 494 | 'src' => '', |
||
| 495 | 'relZip' => $relZip, |
||
| 496 | 'is_dir' => false, |
||
| 497 | 'debug' => $attempts, |
||
| 498 | ]; |
||
| 499 | |||
| 500 | continue; |
||
| 501 | } |
||
| 502 | |||
| 503 | $docs[] = [ |
||
| 504 | 'src' => $abs, |
||
| 505 | 'relZip' => $relZip, |
||
| 506 | 'is_dir' => false, |
||
| 507 | 'debug' => $attempts + ['resolved' => $abs, 'generated' => (bool) $generatedAbs], |
||
| 508 | ]; |
||
| 509 | |||
| 510 | $this->log('Resolved document', [ |
||
| 511 | 'relDoc' => $relDoc, |
||
| 512 | 'abs' => $abs, |
||
| 513 | 'generated' => (bool) $generatedAbs, |
||
| 514 | 'pipeline' => $attempts, |
||
| 515 | ], 'info'); |
||
| 516 | } |
||
| 517 | |||
| 518 | return $docs; |
||
| 519 | } |
||
| 520 | |||
| 521 | /** |
||
| 522 | * Collect Web Links from legacy bag. |
||
| 523 | * Tries common keys and fields: url|href|link, title|name, description|comment. |
||
| 524 | * |
||
| 525 | * @return array<int,array{title:string,url:string,description:string}> |
||
| 526 | */ |
||
| 527 | private function collectWebLinks(): array |
||
| 528 | { |
||
| 529 | $out = []; |
||
| 530 | $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; |
||
| 531 | |||
| 532 | $wlKey = $this->firstExistingKey($res, ['weblink', 'weblinks', 'link', 'links', 'Link', 'Links', 'URL', 'Urls', 'urls']); |
||
| 533 | if (!$wlKey || empty($res[$wlKey]) || !\is_array($res[$wlKey])) { |
||
| 534 | $this->log('No Web Links bucket found', ['candidates' => $wlKey], 'debug'); |
||
| 535 | |||
| 536 | return $out; |
||
| 537 | } |
||
| 538 | |||
| 539 | foreach ($res[$wlKey] as $wrap) { |
||
| 540 | if (!\is_object($wrap)) { |
||
| 541 | continue; |
||
| 542 | } |
||
| 543 | $e = $this->unwrap($wrap); |
||
| 544 | |||
| 545 | // URL |
||
| 546 | $url = (string) ($e->url ?? $e->href ?? $e->link ?? ''); |
||
| 547 | if ('' === $url && !empty($e->path) && \is_string($e->path) && preg_match('~^https?://~i', $e->path)) { |
||
| 548 | $url = (string) $e->path; |
||
| 549 | } |
||
| 550 | if ('' === $url || !preg_match('~^https?://~i', $url)) { |
||
| 551 | $this->log('Skipping weblink without valid URL', ['raw' => $url], 'debug'); |
||
| 552 | |||
| 553 | continue; |
||
| 554 | } |
||
| 555 | |||
| 556 | // Title |
||
| 557 | $title = (string) ($e->title ?? $e->name ?? $e->label ?? $url); |
||
| 558 | if ('' === $title) { |
||
| 559 | $title = $url; |
||
| 560 | } |
||
| 561 | |||
| 562 | // Description (optional) |
||
| 563 | $desc = (string) ($e->description ?? $e->comment ?? $e->content ?? ''); |
||
| 564 | |||
| 565 | $out[] = ['title' => $title, 'url' => $url, 'description' => $desc]; |
||
| 566 | } |
||
| 567 | |||
| 568 | $this->log('Web Links collected', ['count' => \count($out)], 'info'); |
||
| 569 | |||
| 570 | return $out; |
||
| 571 | } |
||
| 572 | |||
| 573 | /** |
||
| 574 | * Collect Discussion Topics from legacy bag (Chamilo-friendly). |
||
| 575 | * - Prefer explicit topic buckets: forum_topic | ForumTopic | thread | Thread |
||
| 576 | * - Pull body from the first post in post/forum_post if topic has no text |
||
| 577 | * - Fallback: if no topics, create one topic per forum using forum title/description. |
||
| 578 | * |
||
| 579 | * @return array<int,array{title:string,body:string}> |
||
| 580 | */ |
||
| 581 | private function collectDiscussionTopics(): array |
||
| 582 | { |
||
| 583 | $out = []; |
||
| 584 | $res = \is_array($this->course->resources ?? null) ? $this->course->resources : []; |
||
| 585 | |||
| 586 | // Buckets |
||
| 587 | $topicKey = $this->firstExistingKey($res, ['forum_topic', 'ForumTopic', 'thread', 'Thread']); |
||
| 588 | $postKey = $this->firstExistingKey($res, ['forum_post', 'Forum_Post', 'post', 'Post']); |
||
| 589 | $forumKey = $this->firstExistingKey($res, ['forum', 'Forum']); |
||
| 590 | |||
| 591 | // Map first post text by thread/topic id |
||
| 592 | $firstPostByThread = []; |
||
| 593 | if ($postKey && \is_array($res[$postKey])) { |
||
| 594 | foreach ($res[$postKey] as $pid => $pWrap) { |
||
| 595 | if (!\is_object($pWrap)) { |
||
| 596 | continue; |
||
| 597 | } |
||
| 598 | $p = $this->unwrap($pWrap); |
||
| 599 | $tid = (int) ($p->thread_id ?? $p->topic_id ?? 0); |
||
| 600 | if ($tid <= 0) { |
||
| 601 | continue; |
||
| 602 | } |
||
| 603 | |||
| 604 | // Common fields for post content |
||
| 605 | $txt = (string) ($p->post_text ?? $p->message ?? $p->text ?? $p->content ?? $p->comment ?? ''); |
||
| 606 | if ('' === $txt) { |
||
| 607 | continue; |
||
| 608 | } |
||
| 609 | |||
| 610 | // keep the first one we see (starter post) |
||
| 611 | if (!isset($firstPostByThread[$tid])) { |
||
| 612 | $firstPostByThread[$tid] = $txt; |
||
| 613 | } |
||
| 614 | } |
||
| 615 | } |
||
| 616 | |||
| 617 | // Topics from topic bucket(s) |
||
| 618 | if ($topicKey && \is_array($res[$topicKey])) { |
||
| 619 | foreach ($res[$topicKey] as $tid => $tWrap) { |
||
| 620 | if (!\is_object($tWrap)) { |
||
| 621 | continue; |
||
| 622 | } |
||
| 623 | $t = $this->unwrap($tWrap); |
||
| 624 | |||
| 625 | // Title fields typical in Chamilo forum topics/threads |
||
| 626 | $title = (string) ($t->thread_title ?? $t->title ?? $t->subject ?? ''); |
||
| 627 | if ('' === $title) { |
||
| 628 | $title = 'Discussion'; |
||
| 629 | } |
||
| 630 | |||
| 631 | // Body: look on the topic first, otherwise fallback to first post text |
||
| 632 | $body = (string) ($t->text ?? $t->description ?? $t->comment ?? $t->content ?? ''); |
||
| 633 | if ('' === $body) { |
||
| 634 | $body = $firstPostByThread[(int) ($t->id ?? $tid)] |
||
| 635 | ?? $firstPostByThread[(int) ($t->thread_id ?? 0)] |
||
| 636 | ?? ''; |
||
| 637 | } |
||
| 638 | |||
| 639 | $out[] = ['title' => $title, 'body' => $body]; |
||
| 640 | } |
||
| 641 | } |
||
| 642 | |||
| 643 | // Fallback: if no explicit topics, create one topic per forum |
||
| 644 | if (empty($out) && $forumKey && \is_array($res[$forumKey])) { |
||
| 645 | foreach ($res[$forumKey] as $fid => $fWrap) { |
||
| 646 | if (!\is_object($fWrap)) { |
||
| 647 | continue; |
||
| 648 | } |
||
| 649 | $f = $this->unwrap($fWrap); |
||
| 650 | $title = (string) ($f->forum_title ?? $f->title ?? 'Forum'); |
||
| 651 | $desc = (string) ($f->forum_comment ?? $f->description ?? $f->comment ?? ''); |
||
| 652 | $out[] = ['title' => $title, 'body' => $desc]; |
||
| 653 | } |
||
| 654 | } |
||
| 655 | |||
| 656 | $this->log('Discussion Topics collected', ['count' => \count($out)], 'info'); |
||
| 657 | |||
| 658 | return $out; |
||
| 659 | } |
||
| 660 | |||
| 661 | /** |
||
| 662 | * Copy documents into workdir/resources and produce manifest resource list. |
||
| 663 | * |
||
| 664 | * @param array<int,array{src:string, relZip:string, is_dir:bool, debug?:array}> $docs |
||
| 665 | * |
||
| 666 | * @return array<int,array{identifier:string, href:string, type:string, title?:string, path?:string}> |
||
| 667 | */ |
||
| 668 | private function copyDocumentsIntoPackage(array $docs): array |
||
| 669 | { |
||
| 670 | $out = []; |
||
| 671 | |||
| 672 | foreach ($docs as $d) { |
||
| 673 | $targetRel = $d['relZip']; |
||
| 674 | $targetAbs = $this->workdir.'/'.$targetRel; |
||
| 675 | |||
| 676 | if ($d['is_dir']) { |
||
| 677 | $this->mkdirp($targetAbs); |
||
| 678 | |||
| 679 | continue; // folders are not listed as <resource> |
||
| 680 | } |
||
| 681 | |||
| 682 | // Ensure parent dir exists |
||
| 683 | $this->mkdirp(\dirname($targetAbs)); |
||
| 684 | |||
| 685 | // Copy or write placeholder + diagnostics |
||
| 686 | if ('' !== $d['src'] && is_file($d['src'])) { |
||
| 687 | if ($this->pathsAreSame($d['src'], $targetAbs)) { |
||
| 688 | // Already generated in place |
||
| 689 | $this->log('Skipping copy (already in place)', ['path' => $targetAbs], 'debug'); |
||
| 690 | } elseif (!@copy($d['src'], $targetAbs)) { |
||
| 691 | $msg = "Failed to copy source file: {$d['src']}\n"; |
||
| 692 | @file_put_contents($targetAbs.'.missing.txt', $msg); |
||
| 693 | $this->writeWhyFile($targetAbs, $d['debug'] ?? [], 'copy_failed', $d['src']); |
||
| 694 | $targetRel .= '.missing.txt'; |
||
| 695 | $this->log('Copy failed', ['src' => $d['src'], 'dst' => $targetAbs], 'warn'); |
||
| 696 | } |
||
| 697 | } else { |
||
| 698 | @file_put_contents($targetAbs.'.missing.txt', "Missing source file (unresolved)\n"); |
||
| 699 | $this->writeWhyFile($targetAbs, $d['debug'] ?? [], 'unresolved', null); |
||
| 700 | $targetRel .= '.missing.txt'; |
||
| 701 | $this->log('Wrote missing placeholder', ['dst' => $targetAbs.'.missing.txt'], 'debug'); |
||
| 702 | } |
||
| 703 | |||
| 704 | // Derive a friendly title and a normalized relative "document" path for organization |
||
| 705 | $relPathUnderResources = preg_replace('#^resources/#', '', $targetRel) ?? $targetRel; |
||
| 706 | $relPathUnderResources = ltrim($relPathUnderResources, '/'); |
||
| 707 | $friendlyTitle = basename(rtrim($relPathUnderResources, '/')); |
||
| 708 | |||
| 709 | // Basic webcontent resource |
||
| 710 | $out[] = [ |
||
| 711 | 'identifier' => 'RES-'.bin2hex(random_bytes(5)), |
||
| 712 | 'href' => $targetRel, |
||
| 713 | 'type' => 'webcontent', |
||
| 714 | 'title' => $friendlyTitle, |
||
| 715 | 'path' => $relPathUnderResources, |
||
| 716 | ]; |
||
| 717 | } |
||
| 718 | |||
| 719 | return $out; |
||
| 720 | } |
||
| 721 | |||
| 722 | /** |
||
| 723 | * Extract segments after "/document", e.g. "/document/Folder/file.pdf" → ["Folder","file.pdf"]. |
||
| 724 | * |
||
| 725 | * @return array<int,string> |
||
| 726 | */ |
||
| 727 | private function extractDocumentSegmentsFromPath(string $raw): array |
||
| 728 | { |
||
| 729 | $decoded = urldecode($raw); |
||
| 730 | // Remove optional leading slash and "document" root (case-insensitive) |
||
| 731 | $rel = preg_replace('~^/?document/?~i', '', ltrim($decoded, '/')) ?? ''; |
||
| 732 | $rel = trim($rel, '/'); |
||
| 733 | |||
| 734 | if ('' === $rel) { |
||
| 735 | return []; |
||
| 736 | } |
||
| 737 | |||
| 738 | // Split and normalize |
||
| 739 | $parts = array_values(array_filter(explode('/', $rel), static fn ($s) => '' !== $s)); |
||
| 740 | |||
| 741 | return array_map(static fn ($s) => trim($s), $parts); |
||
| 742 | } |
||
| 743 | |||
| 744 | /** |
||
| 745 | * STEP 0: Resolve by wrapper source_id (copy scenarios). |
||
| 746 | */ |
||
| 747 | private function resolveByWrapperSourceId(object $wrap, ?array &$log = null): ?string |
||
| 748 | { |
||
| 749 | try { |
||
| 750 | $sourceId = null; |
||
| 751 | if (isset($wrap->source_id) && is_numeric($wrap->source_id)) { |
||
| 752 | $sourceId = (int) $wrap->source_id; |
||
| 753 | } elseif (isset($wrap->extra['source_id']) && is_numeric($wrap->extra['source_id'])) { |
||
| 754 | $sourceId = (int) $wrap->extra['source_id']; |
||
| 755 | } |
||
| 756 | $this->appendAttempt($log, 'source_id_check', ['source_id' => $sourceId]); |
||
| 757 | |||
| 758 | if ($sourceId && $sourceId > 0) { |
||
| 759 | /** @var CDocument|null $doc */ |
||
| 760 | $doc = $this->em->getRepository(CDocument::class)->find($sourceId); |
||
| 761 | $this->appendAttempt($log, 'source_id_entity', ['found' => (bool) $doc]); |
||
| 762 | if ($doc) { |
||
| 763 | $tmp = $this->resolveByDocumentEntityBestFile($doc, $log, 'source_id'); |
||
| 764 | if ($tmp) { |
||
| 765 | return $tmp; |
||
| 766 | } |
||
| 767 | } |
||
| 768 | } |
||
| 769 | } catch (Throwable $e) { |
||
| 770 | $this->appendAttempt($log, 'source_id_error', ['error' => $e->getMessage()]); |
||
| 771 | $this->log('resolveByWrapperSourceId error', ['e' => $e->getMessage()], 'debug'); |
||
| 772 | } |
||
| 773 | |||
| 774 | return null; |
||
| 775 | } |
||
| 776 | |||
| 777 | /** |
||
| 778 | * STEP 1: Resolve by the actual entity ResourceNode (handles multiple files). |
||
| 779 | */ |
||
| 780 | private function resolveByEntityResourceNodeBestFile(object $entity, ?array &$log = null): ?string |
||
| 781 | { |
||
| 782 | try { |
||
| 783 | $iid = null; |
||
| 784 | if (isset($entity->iid) && is_numeric($entity->iid)) { |
||
| 785 | $iid = (int) $entity->iid; |
||
| 786 | } elseif (isset($entity->id) && is_numeric($entity->id)) { |
||
| 787 | $iid = (int) $entity->id; |
||
| 788 | } elseif (method_exists($entity, 'getIid')) { |
||
| 789 | $iid = (int) $entity->getIid(); |
||
| 790 | } |
||
| 791 | $this->appendAttempt($log, 'entity_iid', ['iid' => $iid]); |
||
| 792 | |||
| 793 | if ($iid && $iid > 0) { |
||
| 794 | /** @var CDocument|null $doc */ |
||
| 795 | $doc = $this->em->getRepository(CDocument::class)->find($iid); |
||
| 796 | $this->appendAttempt($log, 'entity_doc_found', ['found' => (bool) $doc]); |
||
| 797 | if ($doc) { |
||
| 798 | return $this->resolveByDocumentEntityBestFile($doc, $log, 'entity_rn'); |
||
| 799 | } |
||
| 800 | } |
||
| 801 | } catch (Throwable $e) { |
||
| 802 | $this->appendAttempt($log, 'entity_rn_error', ['error' => $e->getMessage()]); |
||
| 803 | $this->log('RN resolve error', ['e' => $e->getMessage()], 'debug'); |
||
| 804 | } |
||
| 805 | |||
| 806 | return null; |
||
| 807 | } |
||
| 808 | |||
| 809 | /** |
||
| 810 | * Resolve best absolute path for any CDocument entity by inspecting all ResourceFiles. |
||
| 811 | * Strategy: prefer the largest regular file (non-zero size), otherwise the first readable file. |
||
| 812 | */ |
||
| 813 | private function resolveByDocumentEntityBestFile(CDocument $doc, ?array &$log = null, string $tag = 'rn'): ?string |
||
| 814 | { |
||
| 815 | $rn = $doc->getResourceNode(); |
||
| 816 | $this->appendAttempt($log, $tag.'_rn_present', ['present' => (bool) $rn]); |
||
| 817 | if (!$rn) { |
||
| 818 | return null; |
||
| 819 | } |
||
| 820 | |||
| 821 | // Try "first file" fast-path |
||
| 822 | if (method_exists($rn, 'getFirstResourceFile') && ($file = $rn->getFirstResourceFile())) { |
||
| 823 | $abs = $this->absolutePathFromResourceFile($file, $log, $tag.'_first'); |
||
| 824 | if ($abs) { |
||
| 825 | return $abs; |
||
| 826 | } |
||
| 827 | } |
||
| 828 | |||
| 829 | // Iterate all files if available |
||
| 830 | if (method_exists($rn, 'getResourceFiles')) { |
||
| 831 | $bestAbs = null; |
||
| 832 | $bestSize = -1; |
||
| 833 | |||
| 834 | foreach ($rn->getResourceFiles() as $idx => $file) { |
||
| 835 | $abs = $this->absolutePathFromResourceFile($file, $log, $tag.'_iter_'.$idx); |
||
| 836 | if (!$abs) { |
||
| 837 | continue; |
||
| 838 | } |
||
| 839 | $sz = @filesize($abs); |
||
| 840 | if (false === $sz) { |
||
| 841 | $sz = -1; |
||
| 842 | } |
||
| 843 | $this->appendAttempt($log, $tag.'_iter_size_'.$idx, ['size' => $sz]); |
||
| 844 | if ($sz > $bestSize) { |
||
| 845 | $bestSize = $sz; |
||
| 846 | $bestAbs = $abs; |
||
| 847 | } |
||
| 848 | } |
||
| 849 | |||
| 850 | if ($bestAbs) { |
||
| 851 | $this->appendAttempt($log, $tag.'_best', ['abs' => $bestAbs, 'size' => $bestSize]); |
||
| 852 | |||
| 853 | return $bestAbs; |
||
| 854 | } |
||
| 855 | } else { |
||
| 856 | $this->appendAttempt($log, $tag.'_no_iter', ['hint' => 'getResourceFiles not available']); |
||
| 857 | } |
||
| 858 | |||
| 859 | return null; |
||
| 860 | } |
||
| 861 | |||
| 862 | /** |
||
| 863 | * Convert a ResourceFile into an absolute path under any of the known upload roots. |
||
| 864 | * Tries several common roots: var/upload/resource, var/data/upload/resource, public/upload/resource, web/upload/resource. |
||
| 865 | */ |
||
| 866 | private function absolutePathFromResourceFile(object $resourceFile, ?array &$log = null, string $tag = 'rf'): ?string |
||
| 867 | { |
||
| 868 | try { |
||
| 869 | // Most Chamilo builds: repository returns a stored relative filename like "/ab/cd/ef123.bin" |
||
| 870 | $storedRel = (string) $this->rnRepo->getFilename($resourceFile); |
||
| 871 | $this->appendAttempt($log, $tag.'_storedRel', ['storedRel' => $storedRel]); |
||
| 872 | |||
| 873 | if ('' !== $storedRel) { |
||
| 874 | foreach ($this->getUploadBaseCandidates() as $root) { |
||
| 875 | $abs = rtrim($root, '/').$storedRel; |
||
| 876 | $ok = (is_readable($abs) && is_file($abs)); |
||
| 877 | $this->appendAttempt($log, $tag.'_abs_try', ['root' => $root, 'abs' => $abs, 'ok' => $ok]); |
||
| 878 | if ($ok) { |
||
| 879 | return $abs; |
||
| 880 | } |
||
| 881 | } |
||
| 882 | } |
||
| 883 | |||
| 884 | // Some builds might offer a direct path method |
||
| 885 | if (method_exists($this->rnRepo, 'getAbsolutePath')) { |
||
| 886 | $abs2 = (string) $this->rnRepo->getAbsolutePath($resourceFile); |
||
| 887 | $ok2 = ('' !== $abs2 && is_readable($abs2) && is_file($abs2)); |
||
| 888 | $this->appendAttempt($log, $tag.'_abs2_try', ['abs2' => $abs2, 'ok' => $ok2]); |
||
| 889 | if ($ok2) { |
||
| 890 | return $abs2; |
||
| 891 | } |
||
| 892 | } |
||
| 893 | } catch (Throwable $e) { |
||
| 894 | $this->appendAttempt($log, $tag.'_error', ['error' => $e->getMessage()]); |
||
| 895 | $this->log('absolutePathFromResourceFile error', ['e' => $e->getMessage()], 'debug'); |
||
| 896 | } |
||
| 897 | |||
| 898 | return null; |
||
| 899 | } |
||
| 900 | |||
| 901 | /** |
||
| 902 | * Walk children by title from the course documents root and return the absolute file path. |
||
| 903 | * |
||
| 904 | * @param array<int,string> $segments |
||
| 905 | * |
||
| 906 | * @return string|null Absolute path or null if not found |
||
| 907 | */ |
||
| 908 | private function resolveAbsoluteForDocumentSegments(array $segments, ?array &$log = null): ?string |
||
| 909 | { |
||
| 910 | if (!$this->courseEntity instanceof CourseEntity) { |
||
| 911 | $this->appendAttempt($log, 'titlewalk_no_course', []); |
||
| 912 | |||
| 913 | return null; |
||
| 914 | } |
||
| 915 | |||
| 916 | $root = $this->docRepo->getCourseDocumentsRootNode($this->courseEntity); |
||
| 917 | $this->appendAttempt($log, 'titlewalk_root_present', ['present' => (bool) $root]); |
||
| 918 | if (!$root) { |
||
| 919 | return null; |
||
| 920 | } |
||
| 921 | |||
| 922 | $node = $root; |
||
| 923 | foreach ($segments as $title) { |
||
| 924 | $child = $this->docRepo->findChildNodeByTitle($node, $title); |
||
| 925 | $this->appendAttempt($log, 'titlewalk_step', ['title' => $title, 'found' => (bool) $child]); |
||
| 926 | if (!$child) { |
||
| 927 | return null; |
||
| 928 | } |
||
| 929 | $node = $child; |
||
| 930 | } |
||
| 931 | |||
| 932 | $file = method_exists($node, 'getFirstResourceFile') ? $node->getFirstResourceFile() : null; |
||
| 933 | $this->appendAttempt($log, 'titlewalk_file_present', ['present' => (bool) $file]); |
||
| 934 | if (!$file) { |
||
| 935 | return null; |
||
| 936 | } |
||
| 937 | |||
| 938 | $abs = $this->absolutePathFromResourceFile($file, $log, 'titlewalk_rf'); |
||
| 939 | if ($abs && is_readable($abs) && is_file($abs)) { |
||
| 940 | return $abs; |
||
| 941 | } |
||
| 942 | |||
| 943 | return null; |
||
| 944 | } |
||
| 945 | |||
| 946 | /** |
||
| 947 | * Legacy filesystem fallback under courses/<CODE>/document/... |
||
| 948 | * Tries a few common base paths used in older deployments. |
||
| 949 | */ |
||
| 950 | private function resolveLegacyFilesystemBySegments(array $segments, ?array &$log = null): ?string |
||
| 951 | { |
||
| 952 | if ('' === $this->courseCodeForLegacy) { |
||
| 953 | $this->appendAttempt($log, 'legacy_no_code', []); |
||
| 954 | |||
| 955 | return null; |
||
| 956 | } |
||
| 957 | |||
| 958 | $bases = [ |
||
| 959 | $this->projectDir.'/var/courses/'.$this->courseCodeForLegacy.'/document', |
||
| 960 | $this->projectDir.'/app/courses/'.$this->courseCodeForLegacy.'/document', |
||
| 961 | $this->projectDir.'/courses/'.$this->courseCodeForLegacy.'/document', |
||
| 962 | ]; |
||
| 963 | $rel = implode('/', $segments); |
||
| 964 | |||
| 965 | foreach ($bases as $base) { |
||
| 966 | $cand = rtrim($base, '/').'/'.$rel; |
||
| 967 | $ok = (is_readable($cand) && is_file($cand)); |
||
| 968 | $this->appendAttempt($log, 'legacy_try', ['candidate' => $cand, 'ok' => $ok]); |
||
| 969 | if ($ok) { |
||
| 970 | return $cand; |
||
| 971 | } |
||
| 972 | } |
||
| 973 | |||
| 974 | return null; |
||
| 975 | } |
||
| 976 | |||
| 977 | /** |
||
| 978 | * Some documents are inline HTML stored in DB (no ResourceFile). |
||
| 979 | * If we can read text content from the entity, generate a temp file to export. |
||
| 980 | * |
||
| 981 | * Returns the absolute path of the generated file, or null. |
||
| 982 | */ |
||
| 983 | private function generateFromInlineContentIfAny(object $entity, string $relDoc, ?array &$log = null): ?string |
||
| 984 | { |
||
| 985 | try { |
||
| 986 | $content = null; |
||
| 987 | if (method_exists($entity, 'getContent')) { |
||
| 988 | $content = $entity->getContent(); |
||
| 989 | } elseif (isset($entity->content)) { |
||
| 990 | $content = $entity->content; |
||
| 991 | } elseif (isset($entity->comment) && \is_string($entity->comment) && strip_tags($entity->comment) !== $entity->comment) { |
||
| 992 | $content = $entity->comment; |
||
| 993 | } |
||
| 994 | |||
| 995 | $text = (string) ($content ?? ''); |
||
| 996 | $this->appendAttempt($log, 'inline_check', ['hasContent' => ('' !== $text)]); |
||
| 997 | |||
| 998 | if ('' === $text) { |
||
| 999 | return null; |
||
| 1000 | } |
||
| 1001 | |||
| 1002 | $ext = strtolower(pathinfo($relDoc, PATHINFO_EXTENSION)); |
||
| 1003 | if ('' === $ext) { |
||
| 1004 | $relDoc .= '.html'; |
||
| 1005 | } |
||
| 1006 | |||
| 1007 | $generatedAbs = $this->workdir.'/_generated/'.$relDoc; |
||
| 1008 | $this->mkdirp(\dirname($generatedAbs)); |
||
| 1009 | |||
| 1010 | $payload = $text; |
||
| 1011 | $looksHtml = false !== stripos($text, '<html') || false !== stripos($text, '<!doctype') || false !== stripos($text, '<body'); |
||
| 1012 | if (!$looksHtml) { |
||
| 1013 | $payload = "<!doctype html><html><meta charset=\"utf-8\"><body>\n".$text."\n</body></html>"; |
||
| 1014 | } |
||
| 1015 | |||
| 1016 | if (false === @file_put_contents($generatedAbs, $payload)) { |
||
| 1017 | $this->appendAttempt($log, 'inline_write_fail', ['path' => $generatedAbs]); |
||
| 1018 | |||
| 1019 | return null; |
||
| 1020 | } |
||
| 1021 | |||
| 1022 | $this->appendAttempt($log, 'inline_generated', ['path' => $generatedAbs]); |
||
| 1023 | |||
| 1024 | return $generatedAbs; |
||
| 1025 | } catch (Throwable $e) { |
||
| 1026 | $this->appendAttempt($log, 'inline_error', ['error' => $e->getMessage()]); |
||
| 1027 | $this->log('generateFromInlineContentIfAny error', ['e' => $e->getMessage()], 'debug'); |
||
| 1028 | |||
| 1029 | return null; |
||
| 1030 | } |
||
| 1031 | } |
||
| 1032 | |||
| 1033 | private function unwrap(object $o): object |
||
| 1034 | { |
||
| 1035 | return (isset($o->obj) && \is_object($o->obj)) ? $o->obj : $o; |
||
| 1036 | } |
||
| 1037 | |||
| 1038 | /** |
||
| 1039 | * Get first existing key from a set of candidates. |
||
| 1040 | */ |
||
| 1041 | private function firstExistingKey(array $arr, array $candidates): ?string |
||
| 1042 | { |
||
| 1043 | foreach ($candidates as $k) { |
||
| 1044 | if ('' === $k || null === $k) { |
||
| 1045 | continue; |
||
| 1046 | } |
||
| 1047 | if (isset($arr[$k]) && \is_array($arr[$k]) && !empty($arr[$k])) { |
||
| 1048 | return (string) $k; |
||
| 1049 | } |
||
| 1050 | } |
||
| 1051 | |||
| 1052 | return null; |
||
| 1053 | } |
||
| 1054 | |||
| 1055 | private function mkdirp(string $path): void |
||
| 1056 | { |
||
| 1057 | if (!is_dir($path) && !@mkdir($path, 0775, true) && !is_dir($path)) { |
||
| 1058 | $this->log('Failed to create directory', ['path' => $path], 'error'); |
||
| 1059 | |||
| 1060 | throw new RuntimeException('Failed to create directory: '.$path); |
||
| 1061 | } |
||
| 1062 | } |
||
| 1063 | |||
| 1064 | private function rrmdir(string $path): void |
||
| 1065 | { |
||
| 1066 | if (!is_dir($path)) { |
||
| 1067 | return; |
||
| 1068 | } |
||
| 1069 | $it = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS); |
||
| 1070 | $ri = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); |
||
| 1071 | foreach ($ri as $file) { |
||
| 1072 | $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); |
||
| 1073 | } |
||
| 1074 | @rmdir($path); |
||
| 1075 | } |
||
| 1076 | |||
| 1077 | private function normalizePath(string $p): string |
||
| 1078 | { |
||
| 1079 | $p = str_replace('\\', '/', $p); |
||
| 1080 | |||
| 1081 | return preg_replace('#/+#', '/', $p) ?? $p; |
||
| 1082 | } |
||
| 1083 | |||
| 1084 | private function zipDir(string $dir, string $zipPath): void |
||
| 1085 | { |
||
| 1086 | $zip = new ZipArchive(); |
||
| 1087 | if (true !== $zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE)) { |
||
| 1088 | $this->log('Cannot open zip', ['zipPath' => $zipPath], 'error'); |
||
| 1089 | |||
| 1090 | throw new RuntimeException('Cannot open zip: '.$zipPath); |
||
| 1091 | } |
||
| 1092 | |||
| 1093 | $dir = rtrim($dir, DIRECTORY_SEPARATOR); |
||
| 1094 | |||
| 1095 | $it = new RecursiveIteratorIterator( |
||
| 1096 | new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS), |
||
| 1097 | RecursiveIteratorIterator::LEAVES_ONLY |
||
| 1098 | ); |
||
| 1099 | |||
| 1100 | foreach ($it as $file) { |
||
| 1101 | /** @var SplFileInfo $file */ |
||
| 1102 | if (!$file->isFile()) { |
||
| 1103 | continue; |
||
| 1104 | } |
||
| 1105 | $abs = $file->getRealPath(); |
||
| 1106 | $rel = substr($abs, \strlen($dir) + 1); |
||
| 1107 | $rel = str_replace('\\', '/', $rel); |
||
| 1108 | $zip->addFile($abs, $rel); |
||
| 1109 | } |
||
| 1110 | |||
| 1111 | $zip->close(); |
||
| 1112 | } |
||
| 1113 | |||
| 1114 | private function pathsAreSame(string $a, string $b): bool |
||
| 1115 | { |
||
| 1116 | $na = $this->normalizePath($a); |
||
| 1117 | $nb = $this->normalizePath($b); |
||
| 1118 | |||
| 1119 | return rtrim($na, '/') === rtrim($nb, '/'); |
||
| 1120 | } |
||
| 1121 | |||
| 1122 | /** |
||
| 1123 | * Write a companion .why.txt file with pipeline diagnostics for a given missing file. |
||
| 1124 | */ |
||
| 1125 | private function writeWhyFile(string $targetAbsNoExt, array $attempts, string $reason, ?string $src): void |
||
| 1126 | { |
||
| 1127 | try { |
||
| 1128 | $whyPath = $targetAbsNoExt.'.why.txt'; |
||
| 1129 | $payload = "CC13 resolution diagnostics\n" |
||
| 1130 | ."Reason: {$reason}\n" |
||
| 1131 | .'Source: '.($src ?? '(none)')."\n" |
||
| 1132 | ."Attempts (JSON):\n" |
||
| 1133 | .json_encode($attempts, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) |
||
| 1134 | ."\n"; |
||
| 1135 | @file_put_contents($whyPath, $payload); |
||
| 1136 | } catch (Throwable $e) { |
||
| 1137 | $this->log('Failed to write .why.txt', ['dst' => $targetAbsNoExt, 'e' => $e->getMessage()], 'debug'); |
||
| 1138 | } |
||
| 1139 | } |
||
| 1140 | |||
| 1141 | /** |
||
| 1142 | * Write Web Links (IMS CC) files and return resource entries. |
||
| 1143 | * |
||
| 1144 | * @param array<int,array{title:string,url:string,description:string}> $links |
||
| 1145 | * |
||
| 1146 | * @return array<int,array{identifier:string, href:string, type:string, title:string, path:string}> |
||
| 1147 | */ |
||
| 1148 | private function writeWebLinksIntoPackage(array $links): array |
||
| 1149 | { |
||
| 1150 | $out = []; |
||
| 1151 | foreach ($links as $idx => $l) { |
||
| 1152 | $id = 'WL-'.substr(sha1($l['title'].$l['url'].$idx.random_bytes(2)), 0, 10); |
||
| 1153 | $fn = $this->workdir.'/weblinks/'.$id.'.xml'; |
||
| 1154 | $rel = 'weblinks/'.$id.'.xml'; |
||
| 1155 | |||
| 1156 | // Build minimal IMS WebLink XML (v1p1 works across CC versions) |
||
| 1157 | $doc = new DOMDocument('1.0', 'UTF-8'); |
||
| 1158 | $doc->formatOutput = true; |
||
| 1159 | |||
| 1160 | $ns = 'http://www.imsglobal.org/xsd/imswl_v1p1'; |
||
| 1161 | $root = $doc->createElementNS($ns, 'wl:webLink'); |
||
| 1162 | |||
| 1163 | $t = $doc->createElementNS($ns, 'wl:title'); |
||
| 1164 | $t->nodeValue = $l['title']; |
||
| 1165 | $root->appendChild($t); |
||
| 1166 | |||
| 1167 | $url = $doc->createElementNS($ns, 'wl:url'); |
||
| 1168 | $url->setAttribute('href', $l['url']); |
||
| 1169 | $root->appendChild($url); |
||
| 1170 | |||
| 1171 | if ('' !== $l['description']) { |
||
| 1172 | $d = $doc->createElementNS($ns, 'wl:description'); |
||
| 1173 | $d->nodeValue = $l['description']; |
||
| 1174 | $root->appendChild($d); |
||
| 1175 | } |
||
| 1176 | |||
| 1177 | $doc->appendChild($root); |
||
| 1178 | if (false === @file_put_contents($fn, $doc->saveXML() ?: '')) { |
||
| 1179 | $this->log('Failed to write WebLink XML', ['path' => $fn], 'warn'); |
||
| 1180 | |||
| 1181 | continue; |
||
| 1182 | } |
||
| 1183 | |||
| 1184 | $out[] = [ |
||
| 1185 | 'identifier' => 'RES-'.substr($id, 3), |
||
| 1186 | 'href' => $rel, |
||
| 1187 | 'type' => 'imswl_xmlv1p1', |
||
| 1188 | 'title' => $l['title'], |
||
| 1189 | 'path' => 'Web Links/'.$l['title'].'.xml', |
||
| 1190 | ]; |
||
| 1191 | } |
||
| 1192 | |||
| 1193 | return $out; |
||
| 1194 | } |
||
| 1195 | |||
| 1196 | /** |
||
| 1197 | * Write Discussion Topics (IMS CC) files and return resource entries. |
||
| 1198 | * |
||
| 1199 | * @param array<int,array{title:string,body:string}> $topics |
||
| 1200 | * |
||
| 1201 | * @return array<int,array{identifier:string, href:string, type:string, title:string, path:string}> |
||
| 1202 | */ |
||
| 1203 | private function writeDiscussionTopicsIntoPackage(array $topics): array |
||
| 1204 | { |
||
| 1205 | $out = []; |
||
| 1206 | foreach ($topics as $idx => $tpc) { |
||
| 1207 | $id = 'DT-'.substr(sha1($tpc['title'].$tpc['body'].$idx.random_bytes(2)), 0, 10); |
||
| 1208 | $fn = $this->workdir.'/discussions/'.$id.'.xml'; |
||
| 1209 | $rel = 'discussions/'.$id.'.xml'; |
||
| 1210 | |||
| 1211 | // Build minimal IMS Discussion Topic XML (v1p1 for broad compatibility) |
||
| 1212 | $doc = new DOMDocument('1.0', 'UTF-8'); |
||
| 1213 | $doc->formatOutput = true; |
||
| 1214 | |||
| 1215 | $ns = 'http://www.imsglobal.org/xsd/imsdt_v1p1'; |
||
| 1216 | $root = $doc->createElementNS($ns, 'dt:topic'); |
||
| 1217 | |||
| 1218 | $title = $doc->createElementNS($ns, 'dt:title'); |
||
| 1219 | $title->nodeValue = '' !== $tpc['title'] ? $tpc['title'] : 'Discussion'; |
||
| 1220 | $root->appendChild($title); |
||
| 1221 | |||
| 1222 | $text = $doc->createElementNS($ns, 'dt:text'); |
||
| 1223 | $text->setAttribute('texttype', 'text/html'); |
||
| 1224 | |||
| 1225 | // Put body inside CDATA to preserve HTML |
||
| 1226 | $body = '' !== $tpc['body'] ? $tpc['body'] : ''; |
||
| 1227 | $cdata = $doc->createCDATASection($body); |
||
| 1228 | $text->appendChild($cdata); |
||
| 1229 | |||
| 1230 | $root->appendChild($text); |
||
| 1231 | $doc->appendChild($root); |
||
| 1232 | |||
| 1233 | if (false === @file_put_contents($fn, $doc->saveXML() ?: '')) { |
||
| 1234 | $this->log('Failed to write Discussion XML', ['path' => $fn], 'warn'); |
||
| 1235 | |||
| 1236 | continue; |
||
| 1237 | } |
||
| 1238 | |||
| 1239 | // Safe filename in the logical path used by <organizations> |
||
| 1240 | $safeTitle = $this->safeFileName('' !== $tpc['title'] ? $tpc['title'] : 'Discussion'); |
||
| 1241 | |||
| 1242 | $out[] = [ |
||
| 1243 | 'identifier' => 'RES-'.substr($id, 3), |
||
| 1244 | 'href' => $rel, |
||
| 1245 | 'type' => 'imsdt_xmlv1p1', |
||
| 1246 | 'title' => $tpc['title'], |
||
| 1247 | 'path' => 'Discussions/'.$safeTitle.'.xml', |
||
| 1248 | ]; |
||
| 1249 | } |
||
| 1250 | |||
| 1251 | return $out; |
||
| 1252 | } |
||
| 1253 | |||
| 1254 | /** |
||
| 1255 | * Create a filesystem-safe short filename (still human readable). |
||
| 1256 | */ |
||
| 1257 | private function safeFileName(string $name): string |
||
| 1258 | { |
||
| 1259 | $name = trim($name); |
||
| 1260 | // Replace slashes and other risky chars |
||
| 1261 | $name = preg_replace('/[\/\x00-\x1F?<>\:\"|*]+/u', '_', $name) ?? 'item'; |
||
| 1262 | // Collapse spaces/underscores and trim length |
||
| 1263 | $name = preg_replace('/[ _]+/u', ' ', $name) ?? $name; |
||
| 1264 | $name = trim($name); |
||
| 1265 | if ('' === $name) { |
||
| 1266 | $name = 'item'; |
||
| 1267 | } |
||
| 1268 | // Keep it reasonable for manifest readability |
||
| 1269 | if (\function_exists('mb_substr')) { |
||
| 1270 | $name = mb_substr($name, 0, 80); |
||
| 1271 | } else { |
||
| 1272 | $name = substr($name, 0, 80); |
||
| 1273 | } |
||
| 1274 | |||
| 1275 | return $name; |
||
| 1276 | } |
||
| 1277 | |||
| 1278 | /** |
||
| 1279 | * Environment checks to help diagnose missing files. |
||
| 1280 | */ |
||
| 1281 | private function checkEnvironment(): void |
||
| 1307 | } |
||
| 1308 | } |
||
| 1309 | |||
| 1310 | /** |
||
| 1311 | * Candidate base directories for uploaded ResourceFiles. |
||
| 1312 | * The storedRel from ResourceNodeRepository::getFilename() is appended to each. |
||
| 1313 | */ |
||
| 1314 | private function getUploadBaseCandidates(): array |
||
| 1321 | ]; |
||
| 1322 | } |
||
| 1323 | |||
| 1324 | /** |
||
| 1325 | * Append a step/attempt into the per-item debug array. |
||
| 1326 | */ |
||
| 1327 | private function appendAttempt(?array &$log, string $step, array $data): void |
||
| 1328 | { |
||
| 1329 | if (null === $log) { |
||
| 1330 | return; |
||
| 1331 | } |
||
| 1332 | $log[] = ['step' => $step, 'data' => $data]; |
||
| 1333 | } |
||
| 1334 | |||
| 1335 | /** |
||
| 1336 | * Centralized logger. Writes both to PHP error_log and to a dedicated log file. |
||
| 1337 | */ |
||
| 1338 | private function log(string $msg, array $ctx = [], string $level = 'info'): void |
||
| 1355 | } |
||
| 1356 | } |
||
| 1357 |