1*1729dd47SHenrik Yllemo<?php 2*1729dd47SHenrik Yllemoif (!defined('DOKU_INC')) die(); 3*1729dd47SHenrik Yllemorequire_once __DIR__ . '/classes/StoredZipWriter.php'; 4*1729dd47SHenrik Yllemorequire_once __DIR__ . '/classes/DokuMarkdownConverter.php'; 5*1729dd47SHenrik Yllemo 6*1729dd47SHenrik Yllemoclass helper_plugin_skillforge extends DokuWiki_Plugin { 7*1729dd47SHenrik Yllemo public function exportPage($id, $options = array()) { 8*1729dd47SHenrik Yllemo $id = trim($this->normalizeId($id), ':'); 9*1729dd47SHenrik Yllemo if ($id === '' || strpos($id, ':') === false) { 10*1729dd47SHenrik Yllemo throw new Exception('Skill pages must be inside a namespace to be exported.'); 11*1729dd47SHenrik Yllemo } 12*1729dd47SHenrik Yllemo 13*1729dd47SHenrik Yllemo $parts = explode(':', $id); 14*1729dd47SHenrik Yllemo $sourcePage = array_pop($parts); 15*1729dd47SHenrik Yllemo $namespace = implode(':', $parts); 16*1729dd47SHenrik Yllemo if ($namespace === '') throw new Exception('Namespace is required.'); 17*1729dd47SHenrik Yllemo 18*1729dd47SHenrik Yllemo return $this->exportNamespace($namespace, $sourcePage . '.txt', $options); 19*1729dd47SHenrik Yllemo } 20*1729dd47SHenrik Yllemo 21*1729dd47SHenrik Yllemo public function exportNamespace($namespace, $sourcePage, $options = array()) { 22*1729dd47SHenrik Yllemo global $conf; 23*1729dd47SHenrik Yllemo $namespace = trim((string)$namespace, ':'); 24*1729dd47SHenrik Yllemo if ($namespace === '') throw new Exception('Namespace is required.'); 25*1729dd47SHenrik Yllemo $sourcePage = $sourcePage ?: $this->getConf('default_skill_source'); 26*1729dd47SHenrik Yllemo $recursive = isset($options['recursive']) ? (bool)$options['recursive'] : (bool)$this->getConf('recursive'); 27*1729dd47SHenrik Yllemo $includeMedia = isset($options['include_media']) ? (bool)$options['include_media'] : (bool)$this->getConf('include_media'); 28*1729dd47SHenrik Yllemo 29*1729dd47SHenrik Yllemo $sourceId = $this->resolveSourceId($namespace, $sourcePage); 30*1729dd47SHenrik Yllemo $pages = $this->collectPages($namespace, $recursive); 31*1729dd47SHenrik Yllemo 32*1729dd47SHenrik Yllemo // Be forgiving: the source page may exist even if the scanner missed it 33*1729dd47SHenrik Yllemo // because of DokuWiki storage differences, cleanID rules or installations 34*1729dd47SHenrik Yllemo // with custom datadir/path handling. 35*1729dd47SHenrik Yllemo $sourceFile = $this->pageFile($sourceId); 36*1729dd47SHenrik Yllemo if (is_readable($sourceFile)) { 37*1729dd47SHenrik Yllemo $pages[$sourceId] = $sourceFile; 38*1729dd47SHenrik Yllemo ksort($pages); 39*1729dd47SHenrik Yllemo } 40*1729dd47SHenrik Yllemo 41*1729dd47SHenrik Yllemo if (!$pages) throw new Exception('No pages found in namespace: ' . hsc($namespace)); 42*1729dd47SHenrik Yllemo if (!isset($pages[$sourceId])) { 43*1729dd47SHenrik Yllemo throw new Exception('Skill source page not found: ' . hsc($sourceId) . ' (' . hsc($sourceFile) . ')'); 44*1729dd47SHenrik Yllemo } 45*1729dd47SHenrik Yllemo 46*1729dd47SHenrik Yllemo $converter = new SkillForge_DokuMarkdownConverter(); 47*1729dd47SHenrik Yllemo $baseFolder = $this->safeName($namespace) . '-skill'; 48*1729dd47SHenrik Yllemo $zip = new SkillForge_StoredZipWriter(); 49*1729dd47SHenrik Yllemo $manifest = array( 50*1729dd47SHenrik Yllemo 'name' => $this->safeName($namespace), 51*1729dd47SHenrik Yllemo 'namespace' => $namespace, 52*1729dd47SHenrik Yllemo 'entry' => $this->getConf('output_skill_filename') ?: 'SKILL.md', 53*1729dd47SHenrik Yllemo 'generated_at' => date('c'), 54*1729dd47SHenrik Yllemo 'source' => 'dokuwiki', 55*1729dd47SHenrik Yllemo 'generator' => 'SkillForge', 56*1729dd47SHenrik Yllemo 'files' => array() 57*1729dd47SHenrik Yllemo ); 58*1729dd47SHenrik Yllemo 59*1729dd47SHenrik Yllemo foreach ($pages as $id => $file) { 60*1729dd47SHenrik Yllemo $raw = io_readFile($file, false); 61*1729dd47SHenrik Yllemo $md = $converter->convert($raw); 62*1729dd47SHenrik Yllemo $metadata = $converter->extractMetadata($raw); 63*1729dd47SHenrik Yllemo $outName = ($id === $sourceId) ? ($this->getConf('output_skill_filename') ?: 'SKILL.md') : $this->idToMarkdownFilename($namespace, $id); 64*1729dd47SHenrik Yllemo if ($id === $sourceId) { 65*1729dd47SHenrik Yllemo $md = $this->buildSkillMarkdown($namespace, $metadata, $md, $pages, $sourceId); 66*1729dd47SHenrik Yllemo } else { 67*1729dd47SHenrik Yllemo $md = $this->ensureFrontmatter($id, $namespace, $metadata, $md); 68*1729dd47SHenrik Yllemo } 69*1729dd47SHenrik Yllemo $zip->addFileFromString($baseFolder . '/' . $outName, $md); 70*1729dd47SHenrik Yllemo $manifest['files'][] = $outName; 71*1729dd47SHenrik Yllemo } 72*1729dd47SHenrik Yllemo 73*1729dd47SHenrik Yllemo if ($this->getConf('generate_index')) { 74*1729dd47SHenrik Yllemo $zip->addFileFromString($baseFolder . '/index.md', $this->buildIndex($namespace, $manifest['files'])); 75*1729dd47SHenrik Yllemo $manifest['files'][] = 'index.md'; 76*1729dd47SHenrik Yllemo } 77*1729dd47SHenrik Yllemo 78*1729dd47SHenrik Yllemo $zip->addFileFromString($baseFolder . '/skill.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); 79*1729dd47SHenrik Yllemo 80*1729dd47SHenrik Yllemo if ($includeMedia) { 81*1729dd47SHenrik Yllemo foreach ($this->collectMedia($namespace, $recursive) as $mediaId => $mediaFile) { 82*1729dd47SHenrik Yllemo $zip->addFile($baseFolder . '/media/' . basename($mediaFile), $mediaFile); 83*1729dd47SHenrik Yllemo } 84*1729dd47SHenrik Yllemo } 85*1729dd47SHenrik Yllemo 86*1729dd47SHenrik Yllemo $tmpDir = rtrim($conf['tmpdir'], '/\\') . '/skillforge'; 87*1729dd47SHenrik Yllemo if (!is_dir($tmpDir)) io_mkdir_p($tmpDir); 88*1729dd47SHenrik Yllemo $zipName = $this->makeZipName($namespace); 89*1729dd47SHenrik Yllemo $target = $tmpDir . '/' . $zipName; 90*1729dd47SHenrik Yllemo if (!$zip->save($target)) throw new Exception('Could not write ZIP file to tmp directory.'); 91*1729dd47SHenrik Yllemo return array('file' => $target, 'name' => $zipName, 'count' => count($pages)); 92*1729dd47SHenrik Yllemo } 93*1729dd47SHenrik Yllemo 94*1729dd47SHenrik Yllemo 95*1729dd47SHenrik Yllemo public function listNamespaces() { 96*1729dd47SHenrik Yllemo global $conf; 97*1729dd47SHenrik Yllemo $namespaces = array(); 98*1729dd47SHenrik Yllemo $base = isset($conf['datadir']) ? rtrim($conf['datadir'], '/\\') : ''; 99*1729dd47SHenrik Yllemo if ($base === '' || !is_dir($base)) return array(); 100*1729dd47SHenrik Yllemo 101*1729dd47SHenrik Yllemo $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS)); 102*1729dd47SHenrik Yllemo foreach ($iterator as $file) { 103*1729dd47SHenrik Yllemo if (!$file->isFile() || strtolower($file->getExtension()) !== 'txt') continue; 104*1729dd47SHenrik Yllemo $id = $this->fileToPageId($file->getPathname()); 105*1729dd47SHenrik Yllemo if ($id === '' || strpos($id, ':') === false) continue; 106*1729dd47SHenrik Yllemo $parts = explode(':', $id); 107*1729dd47SHenrik Yllemo array_pop($parts); // remove page name 108*1729dd47SHenrik Yllemo $current = array(); 109*1729dd47SHenrik Yllemo foreach ($parts as $part) { 110*1729dd47SHenrik Yllemo if ($part === '') continue; 111*1729dd47SHenrik Yllemo $current[] = $part; 112*1729dd47SHenrik Yllemo $namespaces[implode(':', $current)] = true; 113*1729dd47SHenrik Yllemo } 114*1729dd47SHenrik Yllemo } 115*1729dd47SHenrik Yllemo $out = array_keys($namespaces); 116*1729dd47SHenrik Yllemo natcasesort($out); 117*1729dd47SHenrik Yllemo return array_values($out); 118*1729dd47SHenrik Yllemo } 119*1729dd47SHenrik Yllemo 120*1729dd47SHenrik Yllemo public function sendDownload($name) { 121*1729dd47SHenrik Yllemo global $conf; 122*1729dd47SHenrik Yllemo $name = basename((string)$name); 123*1729dd47SHenrik Yllemo if (!preg_match('/\.zip$/i', $name)) throw new Exception('Invalid download filename.'); 124*1729dd47SHenrik Yllemo $file = rtrim($conf['tmpdir'], '/\\') . '/skillforge/' . $name; 125*1729dd47SHenrik Yllemo if (!is_readable($file)) throw new Exception('Export file not found: ' . $name); 126*1729dd47SHenrik Yllemo 127*1729dd47SHenrik Yllemo // Avoid corrupt ZIP downloads if something has already started output. 128*1729dd47SHenrik Yllemo while (ob_get_level() > 0) { 129*1729dd47SHenrik Yllemo @ob_end_clean(); 130*1729dd47SHenrik Yllemo } 131*1729dd47SHenrik Yllemo 132*1729dd47SHenrik Yllemo header('Content-Description: File Transfer'); 133*1729dd47SHenrik Yllemo header('Content-Type: application/zip'); 134*1729dd47SHenrik Yllemo header('Content-Disposition: attachment; filename="' . $name . '"'); 135*1729dd47SHenrik Yllemo header('Content-Transfer-Encoding: binary'); 136*1729dd47SHenrik Yllemo header('Content-Length: ' . filesize($file)); 137*1729dd47SHenrik Yllemo header('Cache-Control: private, no-cache, no-store, must-revalidate'); 138*1729dd47SHenrik Yllemo header('Pragma: public'); 139*1729dd47SHenrik Yllemo header('Expires: 0'); 140*1729dd47SHenrik Yllemo readfile($file); 141*1729dd47SHenrik Yllemo exit; 142*1729dd47SHenrik Yllemo } 143*1729dd47SHenrik Yllemo 144*1729dd47SHenrik Yllemo private function collectPages($namespace, $recursive) { 145*1729dd47SHenrik Yllemo global $conf; 146*1729dd47SHenrik Yllemo $pages = array(); 147*1729dd47SHenrik Yllemo $namespace = trim($this->normalizeId($namespace), ':'); 148*1729dd47SHenrik Yllemo $root = rtrim($conf['datadir'], '/\\') . '/' . str_replace(':', '/', $namespace); 149*1729dd47SHenrik Yllemo 150*1729dd47SHenrik Yllemo // Preferred path: scan the namespace directory directly. 151*1729dd47SHenrik Yllemo if (is_dir($root)) { 152*1729dd47SHenrik Yllemo $iterator = $recursive 153*1729dd47SHenrik Yllemo ? new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)) 154*1729dd47SHenrik Yllemo : new IteratorIterator(new DirectoryIterator($root)); 155*1729dd47SHenrik Yllemo foreach ($iterator as $file) { 156*1729dd47SHenrik Yllemo if (!$file->isFile() || strtolower($file->getExtension()) !== 'txt') continue; 157*1729dd47SHenrik Yllemo $path = $file->getPathname(); 158*1729dd47SHenrik Yllemo $id = $this->fileToPageId($path); 159*1729dd47SHenrik Yllemo if ($id !== '' && ($id === $namespace || strpos($id, $namespace . ':') === 0)) { 160*1729dd47SHenrik Yllemo $pages[$id] = $path; 161*1729dd47SHenrik Yllemo } 162*1729dd47SHenrik Yllemo } 163*1729dd47SHenrik Yllemo } 164*1729dd47SHenrik Yllemo 165*1729dd47SHenrik Yllemo // Fallback path: scan all pages and filter by namespace. This helps on 166*1729dd47SHenrik Yllemo // installations where datadir or storage behavior differs from defaults. 167*1729dd47SHenrik Yllemo if (!$pages && is_dir($conf['datadir'])) { 168*1729dd47SHenrik Yllemo $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($conf['datadir'], FilesystemIterator::SKIP_DOTS)); 169*1729dd47SHenrik Yllemo foreach ($iterator as $file) { 170*1729dd47SHenrik Yllemo if (!$file->isFile() || strtolower($file->getExtension()) !== 'txt') continue; 171*1729dd47SHenrik Yllemo $path = $file->getPathname(); 172*1729dd47SHenrik Yllemo $id = $this->fileToPageId($path); 173*1729dd47SHenrik Yllemo if ($id === '' || $id === $namespace) continue; 174*1729dd47SHenrik Yllemo if ($recursive) { 175*1729dd47SHenrik Yllemo if (strpos($id, $namespace . ':') === 0) $pages[$id] = $path; 176*1729dd47SHenrik Yllemo } else { 177*1729dd47SHenrik Yllemo $tail = substr($id, strlen($namespace . ':')); 178*1729dd47SHenrik Yllemo if (strpos($id, $namespace . ':') === 0 && strpos($tail, ':') === false) $pages[$id] = $path; 179*1729dd47SHenrik Yllemo } 180*1729dd47SHenrik Yllemo } 181*1729dd47SHenrik Yllemo } 182*1729dd47SHenrik Yllemo 183*1729dd47SHenrik Yllemo ksort($pages); 184*1729dd47SHenrik Yllemo return $pages; 185*1729dd47SHenrik Yllemo } 186*1729dd47SHenrik Yllemo 187*1729dd47SHenrik Yllemo private function collectMedia($namespace, $recursive) { 188*1729dd47SHenrik Yllemo global $conf; 189*1729dd47SHenrik Yllemo $root = rtrim($conf['mediadir'], '/\\') . '/' . str_replace(':', '/', $namespace); 190*1729dd47SHenrik Yllemo $media = array(); 191*1729dd47SHenrik Yllemo if (!is_dir($root)) return $media; 192*1729dd47SHenrik Yllemo $iterator = $recursive ? new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)) : new IteratorIterator(new DirectoryIterator($root)); 193*1729dd47SHenrik Yllemo foreach ($iterator as $file) { 194*1729dd47SHenrik Yllemo if (!$file->isFile()) continue; 195*1729dd47SHenrik Yllemo $path = $file->getPathname(); 196*1729dd47SHenrik Yllemo $rel = substr($path, strlen(rtrim($conf['mediadir'], '/\\')) + 1); 197*1729dd47SHenrik Yllemo $media[str_replace('/', ':', $rel)] = $path; 198*1729dd47SHenrik Yllemo } 199*1729dd47SHenrik Yllemo return $media; 200*1729dd47SHenrik Yllemo } 201*1729dd47SHenrik Yllemo 202*1729dd47SHenrik Yllemo private function resolveSourceId($namespace, $sourcePage) { 203*1729dd47SHenrik Yllemo $namespace = trim($this->normalizeId($namespace), ':'); 204*1729dd47SHenrik Yllemo $sourcePage = preg_replace('/\.txt$/i', '', trim((string)$sourcePage)); 205*1729dd47SHenrik Yllemo $sourcePage = trim(str_replace('\\', '/', $sourcePage)); 206*1729dd47SHenrik Yllemo $sourcePage = trim(str_replace('/', ':', $sourcePage), ':'); 207*1729dd47SHenrik Yllemo $sourcePage = $this->normalizeId($sourcePage); 208*1729dd47SHenrik Yllemo if ($sourcePage === '') $sourcePage = 'start'; 209*1729dd47SHenrik Yllemo 210*1729dd47SHenrik Yllemo // Full DokuWiki ID given, e.g. :skilltest:start or skilltest:start. 211*1729dd47SHenrik Yllemo if (strpos($sourcePage, ':') !== false) return trim($sourcePage, ':'); 212*1729dd47SHenrik Yllemo 213*1729dd47SHenrik Yllemo // Relative page given, e.g. start or start.txt. 214*1729dd47SHenrik Yllemo return trim($namespace . ':' . $sourcePage, ':'); 215*1729dd47SHenrik Yllemo } 216*1729dd47SHenrik Yllemo 217*1729dd47SHenrik Yllemo private function normalizeId($id) { 218*1729dd47SHenrik Yllemo $id = trim(str_replace('\\', '/', (string)$id)); 219*1729dd47SHenrik Yllemo $id = trim(str_replace('/', ':', $id), ':'); 220*1729dd47SHenrik Yllemo if (function_exists('cleanID')) return cleanID($id); 221*1729dd47SHenrik Yllemo return strtolower($id); 222*1729dd47SHenrik Yllemo } 223*1729dd47SHenrik Yllemo 224*1729dd47SHenrik Yllemo private function pageFile($id) { 225*1729dd47SHenrik Yllemo global $conf; 226*1729dd47SHenrik Yllemo $id = trim($this->normalizeId($id), ':'); 227*1729dd47SHenrik Yllemo if (function_exists('wikiFN')) return wikiFN($id); 228*1729dd47SHenrik Yllemo return rtrim($conf['datadir'], '/\\') . '/' . str_replace(':', '/', $id) . '.txt'; 229*1729dd47SHenrik Yllemo } 230*1729dd47SHenrik Yllemo 231*1729dd47SHenrik Yllemo private function fileToPageId($path) { 232*1729dd47SHenrik Yllemo global $conf; 233*1729dd47SHenrik Yllemo $base = rtrim(realpath($conf['datadir']) ?: $conf['datadir'], '/\\'); 234*1729dd47SHenrik Yllemo $real = realpath($path) ?: $path; 235*1729dd47SHenrik Yllemo $rel = substr($real, strlen($base) + 1); 236*1729dd47SHenrik Yllemo $rel = str_replace('\\', '/', $rel); 237*1729dd47SHenrik Yllemo if (substr($rel, -4) !== '.txt') return ''; 238*1729dd47SHenrik Yllemo return trim(str_replace('/', ':', substr($rel, 0, -4)), ':'); 239*1729dd47SHenrik Yllemo } 240*1729dd47SHenrik Yllemo 241*1729dd47SHenrik Yllemo private function idToMarkdownFilename($namespace, $id) { 242*1729dd47SHenrik Yllemo $rel = preg_replace('/^' . preg_quote($namespace, '/') . ':?/', '', $id); 243*1729dd47SHenrik Yllemo $rel = trim(str_replace(':', '/', $rel), '/'); 244*1729dd47SHenrik Yllemo if ($rel === '') $rel = 'page'; 245*1729dd47SHenrik Yllemo return $rel . '.md'; 246*1729dd47SHenrik Yllemo } 247*1729dd47SHenrik Yllemo 248*1729dd47SHenrik Yllemo private function buildSkillMarkdown($namespace, $metadata, $body, $pages, $sourceId) { 249*1729dd47SHenrik Yllemo if ($metadata === '') { 250*1729dd47SHenrik Yllemo $metadata = "name: " . $this->safeName($namespace) . "\ndescription: Exported DokuWiki namespace as an AI skill.\nversion: 0.1.0\nsource: dokuwiki\nnamespace: " . $namespace; 251*1729dd47SHenrik Yllemo } 252*1729dd47SHenrik Yllemo $links = "\n\n## Knowledge files\n\n"; 253*1729dd47SHenrik Yllemo foreach ($pages as $id => $file) { 254*1729dd47SHenrik Yllemo if ($id === $sourceId) continue; 255*1729dd47SHenrik Yllemo $links .= '- [' . $this->titleFromId($id) . '](' . $this->idToMarkdownFilename($namespace, $id) . ")\n"; 256*1729dd47SHenrik Yllemo } 257*1729dd47SHenrik Yllemo return "---\n" . trim($metadata) . "\n---\n\n" . trim($body) . $links . "\n"; 258*1729dd47SHenrik Yllemo } 259*1729dd47SHenrik Yllemo 260*1729dd47SHenrik Yllemo private function ensureFrontmatter($id, $namespace, $metadata, $body) { 261*1729dd47SHenrik Yllemo if ($metadata === '') { 262*1729dd47SHenrik Yllemo $metadata = "title: " . $this->titleFromId($id) . "\ntype: page\nsource: dokuwiki\ndokuwiki_id: " . $id . "\nnamespace: " . $namespace; 263*1729dd47SHenrik Yllemo } 264*1729dd47SHenrik Yllemo return "---\n" . trim($metadata) . "\n---\n\n" . trim($body) . "\n"; 265*1729dd47SHenrik Yllemo } 266*1729dd47SHenrik Yllemo 267*1729dd47SHenrik Yllemo private function buildIndex($namespace, $files) { 268*1729dd47SHenrik Yllemo $out = "---\ntitle: Skill Index\ntype: index\nsource: dokuwiki\nnamespace: " . $namespace . "\ngenerator: SkillForge\n---\n\n# Skill Index\n\nThis package was generated from the DokuWiki namespace `" . $namespace . "`.\n\n## Files\n\n"; 269*1729dd47SHenrik Yllemo foreach ($files as $file) { 270*1729dd47SHenrik Yllemo if ($file === 'index.md') continue; 271*1729dd47SHenrik Yllemo $out .= '- [' . $file . '](' . $file . ")\n"; 272*1729dd47SHenrik Yllemo } 273*1729dd47SHenrik Yllemo return $out; 274*1729dd47SHenrik Yllemo } 275*1729dd47SHenrik Yllemo 276*1729dd47SHenrik Yllemo private function makeZipName($namespace) { 277*1729dd47SHenrik Yllemo $pattern = $this->getConf('zip_filename_pattern') ?: '{namespace}-skill-{date}.zip'; 278*1729dd47SHenrik Yllemo $name = str_replace(array('{namespace}', '{date}'), array($this->safeName($namespace), date('Y-m-d')), $pattern); 279*1729dd47SHenrik Yllemo $name = preg_replace('/[^A-Za-z0-9._-]+/', '-', $name); 280*1729dd47SHenrik Yllemo if (!preg_match('/\.zip$/i', $name)) $name .= '.zip'; 281*1729dd47SHenrik Yllemo return $name; 282*1729dd47SHenrik Yllemo } 283*1729dd47SHenrik Yllemo 284*1729dd47SHenrik Yllemo private function safeName($value) { 285*1729dd47SHenrik Yllemo $value = strtolower(str_replace(':', '-', $value)); 286*1729dd47SHenrik Yllemo $value = preg_replace('/[^a-z0-9._-]+/', '-', $value); 287*1729dd47SHenrik Yllemo return trim($value, '-') ?: 'skillforge'; 288*1729dd47SHenrik Yllemo } 289*1729dd47SHenrik Yllemo 290*1729dd47SHenrik Yllemo private function titleFromId($id) { 291*1729dd47SHenrik Yllemo $base = basename(str_replace(':', '/', $id)); 292*1729dd47SHenrik Yllemo return ucwords(str_replace(array('_', '-'), ' ', $base)); 293*1729dd47SHenrik Yllemo } 294*1729dd47SHenrik Yllemo} 295