xref: /plugin/skillforge/helper.php (revision 1729dd472fd5524951126802d13bd2e76b84b680)
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