cms admin first draft, needs a lot of polishing still;

This commit is contained in:
Johann Hoffmann
2026-08-26 11:18:26 +02:00
parent 27765606e3
commit 1802f9d32c
36 changed files with 6121 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
// Preferred organisational unit for a new content.
// If the user is entitled for this unit, it wins.
// If not, the first entitled unit wins.
$config['default_oe_kurzbz'] = 'etw';
+4
View File
@@ -75,6 +75,10 @@ $route['Abgabetool/Student'] = 'Cis/Abgabetool/Student';
$route['Abgabetool/Student/(:any)'] = 'Cis/Abgabetool/Student/$1';
$route['Abgabetool/Deadlines'] = 'Cis/Abgabetool/Deadlines';
// CMS Admin App Routes
$route['cms'] = 'cms/index';
$route['cms/.*'] = 'cms/index';
// Studierendenverwaltung App Routes
$route['studvw'] = 'studentenverwaltung/index';
$route['studvw/.*'] = 'studentenverwaltung/index';
+17
View File
@@ -0,0 +1,17 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class Cms extends Auth_Controller
{
public function __construct()
{
parent::__construct(['index' => 'basis/cms:r']);
$this->loadPhrases(['global', 'ui', 'cms']);
}
public function index()
{
$this->load->view('Cms.php');
}
}
@@ -0,0 +1,472 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdmin extends FHCAPI_Controller
{
const MAX_TREE_DEPTH = 50;
public function __construct()
{
parent::__construct([
'getTree' => ['basis/cms:r'],
'getContent' => ['basis/cms:r'],
'getContentsprache' => ['basis/cms:r'],
'getTemplates' => ['basis/cms:r'],
'getOrganisationseinheiten' => ['basis/cms:r'],
'getSprachen' => ['basis/cms:r'],
'getUsage' => ['basis/cms:r'],
'postContent' => ['basis/cms:rw'],
'postTranslation' => ['basis/cms:rw'],
'postVersion' => ['basis/cms:rw'],
'putProperties' => ['basis/cms:rw'],
'deleteContent' => ['basis/cms:rw'],
'deleteContentsprache' => ['basis/cms:rw']
]);
$this->load->library('CmsAdminLib');
$this->load->library('PermissionLib');
$this->load->model('content/Content_model', 'ContentModel');
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->load->model('content/Template_model', 'TemplateModel');
$this->loadPhrases(['global', 'cms']);
}
public function getTree()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('menu', 'Menu', 'required|in_list[content,news]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$menu = $this->input->get('menu', TRUE);
$filter = $this->input->get('filter', TRUE);
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult);
$nodes = [];
if ($menu === 'news')
{
$newsResult = $this->ContentModel->getNewsContent();
if (!isError($newsResult) && getData($newsResult))
{
foreach (getData($newsResult) as $row)
{
$titel = '';
$csResult = $this->ContentspracheModel->getOne(
(int) $row->content_id, DEFAULT_LANGUAGE
);
if (!isError($csResult))
$titel = getData($csResult)->titel;
$node = new stdClass();
$node->content_id = (int) $row->content_id;
$node->titel = mb_substr($titel, 0, 15) . ' '
. date('d.m.Y', strtotime($row->insertamum));
$node->template_kurzbz = $row->template_kurzbz;
$node->oe_kurzbz = $row->oe_kurzbz;
$node->aktiv = ($row->aktiv === 't' || $row->aktiv === true);
$node->entitled = false;
$node->groups = [];
$node->children = [];
$nodes[] = $node;
}
}
}
else
{
if (!empty($filter))
{
$clean = str_replace(['!', '.', '?', ','], '', $filter);
$searchItems = preg_split('/\s+/', $clean, -1, PREG_SPLIT_NO_EMPTY);
$rootsResult = $this->ContentModel->searchCms($searchItems);
}
else
{
$rootsResult = $this->ContentModel->getRootContent();
}
if (!isError($rootsResult) && getData($rootsResult))
{
$visited = [];
foreach (getData($rootsResult) as $row)
{
$node = $this->buildTreeNode(
(int) $row->content_id, 0, $visited
);
if ($node !== null)
$nodes[] = $node;
}
}
}
$allIds = [];
$this->collectContentIds($nodes, $allIds);
$groupMap = [];
if (!empty($allIds))
{
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
if (!isError($gResult))
$groupMap = getData($gResult);
}
$this->applyMeta($nodes, $entitledOe, $groupMap);
$result = [];
foreach ($nodes as $node)
{
$pruned = $this->pruneUnentitled($node);
if ($pruned !== null)
$result[] = $pruned;
}
$this->terminateWithSuccess($result);
}
public function getContent()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$entitledResult = $this->cmsadminlib->isEntitledForContent($content_id);
$entitled = $this->getDataOrTerminateWithError($entitledResult);
if (!$entitled)
$this->terminateWithError('cms/keineBerechtigungFuerDiesenEintrag');
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
$contentData = $this->getDataOrTerminateWithError($contentResult);
if (empty($contentData))
$this->terminateWithError('cms/contentNichtGefunden');
$content = $contentData[0];
$langResult = $this->ContentspracheModel->getLanguages($content_id);
$languages = $this->getDataOrTerminateWithError($langResult);
$versions = new stdClass();
foreach ($languages as $sprache)
{
$vResult = $this->ContentspracheModel->getVersions($content_id, $sprache);
if (!isError($vResult) && getData($vResult))
{
$versions->$sprache = array_map(function ($v) {
return (int) $v->version;
}, getData($vResult));
}
}
$result = new stdClass();
$result->content_id = (int) $content->content_id;
$result->template_kurzbz = $content->template_kurzbz;
$result->oe_kurzbz = $content->oe_kurzbz;
$result->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
$result->menu_open = ($content->menu_open === 't' || $content->menu_open === true);
$result->beschreibung = $content->beschreibung ?: '';
$result->entitled = true;
$result->languages = $languages;
$result->versions = $versions;
$this->terminateWithSuccess($result);
}
public function getContentsprache()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$result = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($result))
$this->terminateWithError('cms/versionNichtGefunden');
$data = getData($result);
$data->sichtbar = ($data->sichtbar === 't' || $data->sichtbar === true);
$this->terminateWithSuccess($data);
}
public function getTemplates()
{
$this->TemplateModel->addSelect('template_kurzbz, bezeichnung');
$this->TemplateModel->addOrder('bezeichnung');
$result = $this->TemplateModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getOrganisationseinheiten()
{
$this->load->model(
'organisation/Organisationseinheit_model', 'OrganisationseinheitModel'
);
$this->OrganisationseinheitModel->addSelect(
'oe_kurzbz, bezeichnung, organisationseinheittyp_kurzbz, aktiv'
);
$this->OrganisationseinheitModel->addOrder('bezeichnung');
$result = $this->OrganisationseinheitModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getSprachen()
{
$this->load->model('system/Sprache_model', 'SpracheModel');
$this->SpracheModel->addSelect('sprache, bezeichnung');
$result = $this->SpracheModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getUsage()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$result = $this->ContentModel->getUsage($content_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postContent()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('parent_content_id', 'Parent', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$parent = $this->input->post('parent_content_id');
if (empty($parent))
$parent = null;
$result = $this->cmsadminlib->createContent($parent);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postTranslation()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
$this->form_validation->set_rules('target_sprache', 'Target Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->createTranslation(
$this->input->post('content_id'),
$this->input->post('sprache'),
$this->input->post('version'),
$this->input->post('target_sprache')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postVersion()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->createVersion(
$this->input->post('content_id'),
$this->input->post('sprache')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function putProperties()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
$this->form_validation->set_rules('oe_kurzbz', 'OE', 'required');
$this->form_validation->set_rules('titel', 'Titel', 'required|max_length[256]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$daten = [
'content_id' => $this->input->post('content_id'),
'sprache' => $this->input->post('sprache'),
'version' => $this->input->post('version'),
'template_kurzbz' => $this->input->post('template_kurzbz'),
'oe_kurzbz' => $this->input->post('oe_kurzbz'),
'titel' => $this->input->post('titel'),
'beschreibung' => $this->input->post('beschreibung') ?: '',
'aktiv' => filter_var($this->input->post('aktiv'), FILTER_VALIDATE_BOOLEAN),
'menu_open' => filter_var($this->input->post('menu_open'), FILTER_VALIDATE_BOOLEAN),
'sichtbar' => filter_var($this->input->post('sichtbar'), FILTER_VALIDATE_BOOLEAN)
];
$result = $this->cmsadminlib->saveProperties($daten);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteContent()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->deleteContent($this->input->post('content_id'));
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteContentsprache()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
$this->terminateWithError('cms/keineBerechtigung');
$this->validateContentSpracheVersion();
$result = $this->cmsadminlib->deleteVersion(
$this->input->post('content_id'),
$this->input->post('sprache'),
$this->input->post('version')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
// --- Private helpers ---
private function validateContentSpracheVersion()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
}
private function buildTreeNode($content_id, $depth, &$visited)
{
if ($depth > self::MAX_TREE_DEPTH || isset($visited[$content_id]))
return null;
$visited[$content_id] = true;
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
if (isError($contentResult) || empty(getData($contentResult)))
return null;
$content = getData($contentResult)[0];
$titel = '';
$csResult = $this->ContentspracheModel->getOne($content_id, DEFAULT_LANGUAGE);
if (!isError($csResult))
$titel = getData($csResult)->titel;
$childrenResult = $this->ContentchildModel->getChilds($content_id, DEFAULT_LANGUAGE);
$children = [];
if (!isError($childrenResult) && getData($childrenResult))
{
foreach (getData($childrenResult) as $child)
{
$childNode = $this->buildTreeNode(
(int) $child->child_content_id, $depth + 1, $visited
);
if ($childNode !== null)
$children[] = $childNode;
}
}
$node = new stdClass();
$node->content_id = (int) $content_id;
$node->titel = $titel;
$node->template_kurzbz = $content->template_kurzbz;
$node->oe_kurzbz = $content->oe_kurzbz;
$node->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
$node->entitled = false;
$node->groups = [];
$node->children = $children;
return $node;
}
private function collectContentIds($nodes, &$ids)
{
foreach ($nodes as $node)
{
$ids[] = $node->content_id;
if (!empty($node->children))
$this->collectContentIds($node->children, $ids);
}
}
private function applyMeta(&$nodes, $entitledOe, $groupMap)
{
foreach ($nodes as $node)
{
$node->entitled = in_array($node->oe_kurzbz, $entitledOe);
$node->groups = isset($groupMap[$node->content_id])
? $groupMap[$node->content_id]
: [];
if (!empty($node->children))
$this->applyMeta($node->children, $entitledOe, $groupMap);
}
}
private function pruneUnentitled($node)
{
$kept = [];
foreach ($node->children as $child)
{
$pruned = $this->pruneUnentitled($child);
if ($pruned !== null)
$kept[] = $pruned;
}
$node->children = $kept;
if ($node->entitled || !empty($node->children))
return $node;
return null;
}
}
@@ -0,0 +1,193 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdminInhalt extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'getFormSchema' => ['basis/cms:r'],
'getFormData' => ['basis/cms:r'],
'getLock' => ['basis/cms:r'],
'getVersions' => ['basis/cms:r'],
'putFormData' => ['basis/cms:rw'],
'postLock' => ['basis/cms:rw'],
'deleteLock' => ['basis/cms:rw'],
'deleteLockForced' => ['basis/cms:rw']
]);
$this->load->library('CmsAdminLib');
$this->load->library('XsdSchemaLib');
$this->load->library('PermissionLib');
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->load->model('content/Template_model', 'TemplateModel');
$this->loadPhrases(['global', 'cms']);
}
public function getFormSchema()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$template_kurzbz = $this->input->get('template_kurzbz', TRUE);
$this->TemplateModel->resetQuery();
$templateResult = $this->TemplateModel->load($template_kurzbz);
$templateData = $this->getDataOrTerminateWithError($templateResult);
if (empty($templateData))
$this->terminateWithError('cms/vorlageNichtGefunden');
$schemaResult = $this->xsdschemalib->parseSchema(
$templateData[0]->xsd, $template_kurzbz
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($schemaResult));
}
public function getFormData()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$versionResult = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
$row = $this->getDataOrTerminateWithError($versionResult);
$this->load->model('content/Content_model', 'ContentModel');
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
$contentData = $this->getDataOrTerminateWithError($contentResult);
if (empty($contentData))
$this->terminateWithError('cms/contentNichtGefunden');
$this->TemplateModel->resetQuery();
$templateResult = $this->TemplateModel->load($contentData[0]->template_kurzbz);
$templateData = $this->getDataOrTerminateWithError($templateResult);
if (empty($templateData))
$this->terminateWithError('cms/vorlageNichtGefunden');
$schemaResult = $this->xsdschemalib->parseSchema(
$templateData[0]->xsd, $contentData[0]->template_kurzbz
);
$schema = $this->getDataOrTerminateWithError($schemaResult);
$valuesResult = $this->xsdschemalib->extractValues($row->content, $schema);
$values = $this->getDataOrTerminateWithError($valuesResult);
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$lock = $this->getDataOrTerminateWithError($lockResult);
$this->terminateWithSuccess([
'schema' => $schema,
'values' => $values,
'sperre' => $lock
]);
}
public function getLock()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$result = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getVersions()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$result = $this->ContentspracheModel->getVersions($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function putFormData()
{
// LEGACY-QUIRK: the legacy XSDFormPrinter_XML branch in admin.php checks neither the
// permission type nor the lock. Kept as a functional copy. See Q3 in the contract.
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->post('content_id');
$sprache = $this->input->post('sprache');
$version = $this->input->post('version');
$values = $this->input->post('values');
if (is_string($values))
$values = json_decode($values, true);
if (!is_array($values))
$values = [];
$result = $this->cmsadminlib->saveContentXml($content_id, $sprache, $version, $values);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postLock()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigungFuerDieseAktion');
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->lock($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteLock()
{
// LEGACY-QUIRK: releaseOwnLocks releases all locks of the user. This method ignores
// the given contentsprache_id on purpose. The parameter stays in the API contract, so
// that a later fix does not change the interface. See Q1 in the contract.
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigungFuerDieseAktion');
$result = $this->cmsadminlib->releaseOwnLocks();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteLockForced()
{
if (!$this->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su'))
$this->terminateWithError('cms/keineBerechtigungFuerDieseAktion');
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->forceRelease($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
}
@@ -0,0 +1,200 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdminStruktur extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'getGruppen' => ['basis/cms:r'],
'getAllGruppen' => ['basis/cms:r'],
'getChilds' => ['basis/cms:r'],
'getPossibleChilds' => ['basis/cms:r'],
'postGruppe' => ['basis/cms:rw'],
'deleteGruppe' => ['basis/cms:rw'],
'postChild' => ['basis/cms:rw'],
'deleteChild' => ['basis/cms:rw'],
'putChildSort' => ['basis/cms:rw']
]);
$this->load->library('PermissionLib');
$this->load->model('content/Content_model', 'ContentModel');
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->loadPhrases(['global', 'cms']);
}
public function getGruppen()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$result = $this->ContentgruppeModel->getGruppen($content_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getAllGruppen()
{
$result = $this->ContentgruppeModel->getAllGruppen();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getChilds()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentchildModel->getChilds($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getPossibleChilds()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentModel->getPossibleChilds($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postGruppe()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentgruppeModel->insert([
'content_id' => $this->input->post('content_id'),
'gruppe_kurzbz' => $this->input->post('gruppe_kurzbz'),
'insertamum' => date('Y-m-d H:i:s'),
'insertvon' => getAuthUID()
]);
if (isError($result))
{
$this->terminateWithError('cms/gruppeBereitsZugeordnet');
}
$this->terminateWithSuccess(true);
}
public function deleteGruppe()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentgruppeModel->delete([
$this->input->post('gruppe_kurzbz'),
$this->input->post('content_id')
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function postChild()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('child_content_id', 'Child Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->post('content_id');
$sortResult = $this->ContentchildModel->getMaxSort($content_id);
$sort = $this->getDataOrTerminateWithError($sortResult) + 1;
// LEGACY-QUIRK: tbl_contentchild has no unique index on (content_id,
// child_content_id). The legacy code does not check for a duplicate, so a child can be
// attached more than once. Kept as a functional copy. See section 3 of the contract.
$result = $this->ContentchildModel->insert([
'content_id' => $content_id,
'child_content_id' => $this->input->post('child_content_id'),
'sort' => $sort,
'insertamum' => date('Y-m-d H:i:s'),
'insertvon' => getAuthUID()
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function deleteChild()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentchildModel->delete($this->input->post('contentchild_id'));
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function putChildSort()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError('cms/keineBerechtigung');
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
$this->form_validation->set_rules('direction', 'Direction', 'required|in_list[up,down]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentchildModel->swapSort(
$this->input->post('contentchild_id'),
$this->input->post('direction')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
}
+557
View File
@@ -0,0 +1,557 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class CmsAdminLib
{
private $ci;
public function __construct()
{
$this->ci =& get_instance();
$this->ci->load->model('content/Content_model', 'ContentModel');
$this->ci->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->ci->load->model('content/Contentchild_model', 'ContentchildModel');
$this->ci->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->ci->load->model('content/Contentlog_model', 'ContentlogModel');
$this->ci->load->model('content/Template_model', 'TemplateModel');
$this->ci->load->library('XsdSchemaLib');
$this->ci->load->library('PermissionLib');
$this->ci->config->load('cms');
}
/**
* @return object success(array) entitled oe_kurzbz values
*/
public function getEntitledOe()
{
$oe = $this->ci->permissionlib->getOE_isEntitledFor('basis/cms');
if ($oe === false || !is_array($oe))
return success([]);
return success($oe);
}
// LEGACY-QUIRK: the legacy code checks the organisational unit only when it loads the
// page. It does not check on each write. This method exists, but it is called only
// where the legacy code calls it. See Q7 in the contract.
/**
* @param int $content_id
* @return object success(bool)
*/
public function isEntitledForContent($content_id)
{
$oeResult = $this->ci->ContentModel->getOeKurzbz($content_id);
if (isError($oeResult))
return $oeResult;
$entitledResult = $this->getEntitledOe();
if (isError($entitledResult))
return $entitledResult;
return success(in_array(getData($oeResult), getData($entitledResult)));
}
/**
* @return object success(string) or error
*/
public function getDefaultOe()
{
$default = $this->ci->config->item('default_oe_kurzbz');
$entitledResult = $this->getEntitledOe();
if (isError($entitledResult))
return $entitledResult;
$entitled = getData($entitledResult);
if (empty($entitled))
return error('cms/keineBerechtigteOe');
if (in_array($default, $entitled))
return success($default);
return success($entitled[0]);
}
/**
* @param int|null $parent_content_id
* @return object success(array) with content_id, sprache, version
*/
public function createContent($parent_content_id = null)
{
$templatesResult = $this->ci->TemplateModel->loadWhere([]);
if (isError($templatesResult))
return $templatesResult;
$templates = getData($templatesResult);
if (empty($templates))
return error('cms/keineVorlageVorhanden');
$template = $templates[0];
$oeResult = $this->getDefaultOe();
if (isError($oeResult))
return $oeResult;
$oe = getData($oeResult);
$this->ci->load->library('PhrasesLib');
$phraseResult = $this->ci->phraseslib->getPhrases(
'cms', DEFAULT_LANGUAGE, 'neuerEintrag', null, null, 'no'
);
$titel = $phraseResult->retval[0]->text;
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$this->ci->db->trans_start();
$contentResult = $this->ci->ContentModel->insert([
'template_kurzbz' => $template->template_kurzbz,
'oe_kurzbz' => $oe,
'aktiv' => true,
'menu_open' => false,
'beschreibung' => '',
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($contentResult))
return $contentResult;
$content_id = getData($contentResult);
$spracheResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => DEFAULT_LANGUAGE,
'version' => 1,
'sichtbar' => true,
'titel' => $titel,
'content' => '<?xml version="1.0" encoding="UTF-8" ?><content></content>',
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($spracheResult))
return $spracheResult;
if ($parent_content_id !== null)
{
$maxSortResult = $this->ci->ContentchildModel->getMaxSort($parent_content_id);
if (isError($maxSortResult))
return $maxSortResult;
$childResult = $this->ci->ContentchildModel->insert([
'content_id' => $parent_content_id,
'child_content_id' => $content_id,
'sort' => getData($maxSortResult) + 1,
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($childResult))
return $childResult;
}
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/speichernFehlgeschlagen');
return success([
'content_id' => $content_id,
'sprache' => DEFAULT_LANGUAGE,
'version' => 1
]);
}
/**
* @param int $content_id
* @param string $sprache source language
* @param int $version source version
* @param string $neueSprache target language
* @return object success(array) with contentsprache_id
*/
public function createTranslation($content_id, $sprache, $version, $neueSprache)
{
$existsResult = $this->ci->ContentspracheModel->exists($content_id, $neueSprache);
if (isError($existsResult))
return $existsResult;
if (getData($existsResult))
return error('cms/uebersetzungExistiert');
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($sourceResult))
return $sourceResult;
$source = getData($sourceResult);
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$insertResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => $neueSprache,
'version' => $source->version,
'sichtbar' => true,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay and the
// legacy admin still writes them. A copied row must not inherit the review
// stamp of its source, so we clear it here.
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
'insertamum' => $now,
'insertvon' => $uid,
'updateamum' => $now,
'updatevon' => $uid
]);
if (isError($insertResult))
return $insertResult;
return success(['contentsprache_id' => getData($insertResult)]);
}
/**
* @param int $content_id
* @param string $sprache
* @return object success(array) with version
*/
public function createVersion($content_id, $sprache)
{
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, null);
if (isError($sourceResult))
return $sourceResult;
$source = getData($sourceResult);
$maxResult = $this->ci->ContentspracheModel->getMaxVersion($content_id, $sprache);
if (isError($maxResult))
return $maxResult;
$newVersion = getData($maxResult) + 1;
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$insertResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => $sprache,
'version' => $newVersion,
'sichtbar' => false,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay and the
// legacy admin still writes them. A copied row must not inherit the review
// stamp of its source, so we clear it here.
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
'insertamum' => $now,
'insertvon' => $uid,
'updateamum' => $now,
'updatevon' => $uid
]);
if (isError($insertResult))
return $insertResult;
return success(['version' => $newVersion]);
}
/**
* @param array $daten fields from contract 7.7
* @return object success(true) or error
*/
public function saveProperties($daten)
{
$versionResult = $this->ci->ContentspracheModel->getOne(
$daten['content_id'], $daten['sprache'], $daten['version']
);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$this->ci->db->trans_start();
$this->ci->ContentModel->update($daten['content_id'], [
'template_kurzbz' => $daten['template_kurzbz'],
'oe_kurzbz' => $daten['oe_kurzbz'],
'aktiv' => $daten['aktiv'],
'menu_open' => $daten['menu_open'],
'beschreibung' => $daten['beschreibung'],
'updateamum' => $now,
'updatevon' => $uid
]);
// LEGACY-QUIRK: prefs_save sets updateamum and updatevon in tbl_content only.
// It does not touch tbl_contentsprache. Kept as a functional copy.
$this->ci->ContentspracheModel->update($row->contentsprache_id, [
'titel' => $daten['titel'],
'sichtbar' => $daten['sichtbar']
]);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/speichernFehlgeschlagen');
return success(true);
}
// LEGACY-QUIRK: the legacy XSDFormPrinter_XML branch checks neither the permission
// type nor the lock. The form appears only when the user holds the lock, but the POST
// itself is unprotected. See Q3 in the contract.
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @param array $values field name => value
* @return object success(true) or error
*/
public function saveContentXml($content_id, $sprache, $version, $values)
{
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$contentResult = $this->ci->ContentModel->load($content_id);
if (isError($contentResult))
return $contentResult;
$content = getData($contentResult);
if (empty($content))
return error('cms/contentNichtGefunden');
$templateResult = $this->ci->TemplateModel->load($content[0]->template_kurzbz);
if (isError($templateResult))
return $templateResult;
$template = getData($templateResult);
if (empty($template))
return error('cms/keineVorlageVorhanden');
$schemaResult = $this->ci->xsdschemalib->parseSchema(
$template[0]->xsd, $content[0]->template_kurzbz
);
if (isError($schemaResult))
return $schemaResult;
$xmlResult = $this->ci->xsdschemalib->buildXml(getData($schemaResult), $values);
if (isError($xmlResult))
return $xmlResult;
$updateResult = $this->ci->ContentspracheModel->update(
$row->contentsprache_id,
['content' => getData($xmlResult)]
);
if (isError($updateResult))
return $updateResult;
return success(true);
}
// LEGACY-QUIRK: content::sperren does not check for an existing lock.
// A second user overwrites the first lock. See Q2 in the contract.
/**
* @param int $contentsprache_id
* @return object success(true) or error
*/
public function lock($contentsprache_id)
{
$uid = getAuthUID();
$this->ci->db->trans_start();
$logResult = $this->ci->ContentlogModel->insert([
'uid' => $uid,
'contentsprache_id' => $contentsprache_id,
'start' => date('Y-m-d H:i:s')
]);
if (isError($logResult))
return $logResult;
$updateResult = $this->ci->ContentspracheModel->update($contentsprache_id, [
'gesperrt_uid' => $uid
]);
if (isError($updateResult))
return $updateResult;
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/speichernFehlgeschlagen');
return success(true);
}
// LEGACY-QUIRK: content::freigabeUser releases ALL locks of the user, not only the
// lock on this page. Kept as a functional copy. See Q1 in the contract.
/**
* @return object success(true)
*/
public function releaseOwnLocks()
{
$uid = getAuthUID();
$this->ci->db->trans_start();
$this->ci->db->query(
'UPDATE campus.tbl_contentlog SET ende = now() WHERE uid = ? AND ende IS NULL',
[$uid]
);
$this->ci->db->query(
'UPDATE campus.tbl_contentsprache SET gesperrt_uid = NULL WHERE gesperrt_uid = ?',
[$uid]
);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/speichernFehlgeschlagen');
return success(true);
}
/**
* @param int $contentsprache_id
* @return object success(true)
*/
public function forceRelease($contentsprache_id)
{
$this->ci->db->trans_start();
$this->ci->db->query(
'UPDATE campus.tbl_contentlog SET ende = now() WHERE contentsprache_id = ? AND ende IS NULL',
[$contentsprache_id]
);
$this->ci->db->query(
'UPDATE campus.tbl_contentsprache SET gesperrt_uid = NULL WHERE contentsprache_id = ?',
[$contentsprache_id]
);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/speichernFehlgeschlagen');
return success(true);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(array) lock state per contract 7.8
*/
public function getLockState($content_id, $sprache, $version)
{
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$lockResult = $this->ci->ContentlogModel->getOpenLock($row->contentsprache_id);
$logEntry = (!isError($lockResult)) ? getData($lockResult) : null;
// LEGACY-QUIRK: admin.php inserts an empty string, releasing writes NULL. Both mean
// free. The contract reports NULL.
$gesperrt_uid = ($row->gesperrt_uid === '') ? null : $row->gesperrt_uid;
return success([
'contentsprache_id' => (int) $row->contentsprache_id,
'gesperrt_uid' => $gesperrt_uid,
'start' => $logEntry ? $logEntry->start : null,
'own' => ($gesperrt_uid !== null && $gesperrt_uid === getAuthUID()),
'may_force' => $this->ci->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su')
]);
}
// DEVIATION: admin.php ignores the return value of deleteContent and tells the user
// nothing. This code reports the error. See Q5 in the contract.
/**
* @param int $content_id
* @return object success(true) or error
*/
public function deleteContent($content_id)
{
$this->ci->db->trans_start();
$this->ci->db->query(
'DELETE FROM campus.tbl_contentchild WHERE content_id = ?',
[$content_id]
);
$this->ci->db->query(
'DELETE FROM campus.tbl_contentchild WHERE child_content_id = ?',
[$content_id]
);
$this->ci->db->query(
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id IN (SELECT contentsprache_id FROM campus.tbl_contentsprache WHERE content_id = ?)',
[$content_id]
);
$this->ci->db->query(
'DELETE FROM campus.tbl_contentsprache WHERE content_id = ?',
[$content_id]
);
$this->ci->db->query(
'DELETE FROM campus.tbl_contentgruppe WHERE content_id = ?',
[$content_id]
);
$this->ci->db->query(
'DELETE FROM campus.tbl_content WHERE content_id = ?',
[$content_id]
);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/loeschenNichtMoeglichReferenzen');
return success(true);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(true) or error
*/
public function deleteVersion($content_id, $sprache, $version)
{
if ($sprache === DEFAULT_LANGUAGE)
{
$countResult = $this->ci->ContentspracheModel->getNumberOfVersions($content_id, $sprache);
if (isError($countResult))
return $countResult;
if (getData($countResult) === 1)
return error('cms/letzteVersionNichtLoeschbar');
}
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$this->ci->db->trans_start();
$this->ci->db->query(
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id = ?',
[$row->contentsprache_id]
);
$this->ci->db->query(
'DELETE FROM campus.tbl_contentsprache WHERE content_id = ? AND sprache = ? AND version = ?',
[$content_id, $sprache, $version]
);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error('cms/loeschenFehlgeschlagen');
return success(true);
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
use \DOMDocument as DOMDocument;
class XsdSchemaLib
{
/**
* Converts an XSD into a field schema.
* @param string $xsd the XSD as a string
* @param string $templateKurzbz short name of the template
* @return object success(array) with template_kurzbz, root, fields
*/
public function parseSchema($xsd, $templateKurzbz)
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xsd))
{
libxml_clear_errors();
return error('cms/xsdUngueltig');
}
libxml_clear_errors();
$root = null;
$fields = array();
$this->_walkElements($dom->documentElement, $root, $fields);
return success(array(
'template_kurzbz' => $templateKurzbz,
'root' => $root,
'fields' => $fields
));
}
/**
* Reads the values of a content from the XML.
* @param string $xml the value of tbl_contentsprache.content
* @param array $schema the result of parseSchema
* @return object success(array) field name => value
*/
public function extractValues($xml, $schema)
{
$values = array();
foreach ($schema['fields'] as $field)
{
$values[$field['name']] = '';
}
if ($xml === null || trim($xml) === '')
{
return success($values);
}
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xml))
{
libxml_clear_errors();
return success($values);
}
libxml_clear_errors();
foreach ($schema['fields'] as $field)
{
$nodes = $dom->getElementsByTagName($field['name']);
if ($nodes->length > 0)
{
$values[$field['name']] = $nodes->item(0)->nodeValue;
}
}
return success($values);
}
/**
* Builds the XML from the values.
* @param array $schema the result of parseSchema
* @param array $values field name => value
* @return object success(string) the XML
*/
public function buildXml($schema, $values)
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$xml .= "\n<" . $schema['root'] . '>';
foreach ($schema['fields'] as $field)
{
$value = isset($values[$field['name']]) ? $values[$field['name']] : '';
$xml .= "\n<" . $field['name'] . '><![CDATA[' . $this->_escapeCdata($value) . ']]></' . $field['name'] . '>';
}
$xml .= "\n</" . $schema['root'] . '>';
libxml_use_internal_errors(true);
$check = new DOMDocument();
if (!$check->loadXML($xml))
{
libxml_clear_errors();
return error('cms/xmlUngueltig');
}
libxml_clear_errors();
return success($xml);
}
// -------------------------------------------------------------------------
// Private methods
/**
* Walks xs:element nodes recursively.
* The first element without a type attribute is the root.
* Elements with a type attribute are fields.
* xs:complexType and xs:sequence are traversed and ignored.
*/
private function _walkElements($node, &$root, &$fields)
{
if ($node->nodeType !== XML_ELEMENT_NODE)
{
return;
}
$localName = $node->localName;
if ($localName === 'element')
{
$typeAttr = $node->getAttribute('type');
if ($typeAttr === '')
{
if ($root === null)
{
$root = $node->getAttribute('name');
}
}
else
{
$minOccurs = $node->getAttribute('minOccurs');
$required = ($minOccurs === '') ? true : ((int)$minOccurs > 0);
$fields[] = array(
'name' => $node->getAttribute('name'),
'type' => $this->_mapType($typeAttr),
'required' => $required
);
}
}
foreach ($node->childNodes as $child)
{
$this->_walkElements($child, $root, $fields);
}
}
/**
* Maps an XSD type to the field schema type.
*/
private function _mapType($xsdType)
{
$map = array(
'xs:string' => 'string',
'xs:decimal' => 'string',
'xs:integer' => 'string',
'xs:positiveInteger' => 'string',
'xs:date' => 'date',
'wysiwyg' => 'wysiwyg',
'file' => 'file',
'boolean' => 'boolean'
);
return isset($map[$xsdType]) ? $map[$xsdType] : 'string';
}
// Escapes ]]> in the value. The legacy code does not do this and writes broken XML.
private function _escapeCdata($value)
{
return str_replace(']]>', ']]]]><![CDATA[>', $value);
}
}
@@ -323,4 +323,209 @@ class Content_model extends DB_Model
return success(isset($result[$root_content_id]) ? $result[$root_content_id] : null);
}
/**
* Root contents without a parent in tbl_contentchild. Excludes news.
* @return stdClass success with array of rows or error
*/
public function getRootContent()
{
$query = '
SELECT *
FROM (
SELECT DISTINCT ON (content_id) *
FROM campus.tbl_content
LEFT JOIN campus.tbl_contentchild USING (content_id)
WHERE tbl_content.template_kurzbz <> ?
AND content_id NOT IN (
SELECT child_content_id FROM campus.tbl_contentchild
WHERE child_content_id = tbl_content.content_id)
) AS a
ORDER BY sort, content_id
';
return $this->execReadOnlyQuery($query, ['news']);
}
/**
* Recent news from the last two months, max 100 rows.
* @return stdClass success with array of rows or error
*/
public function getNewsContent()
{
$query = "
SELECT *
FROM campus.tbl_content
JOIN campus.tbl_news USING (content_id)
WHERE tbl_news.datum >= now() - '2 month'::interval
ORDER BY datum DESC
LIMIT 100
";
return $this->execReadOnlyQuery($query);
}
/**
* Search contents by content_id or titel. Excludes news. Returns content_ids only.
* @param array $searchItems search terms
* @return stdClass success with array of rows or error
*/
public function searchCms($searchItems)
{
if (empty($searchItems))
return success([]);
$conditions = [];
$params = [];
foreach ($searchItems as $term)
{
$conditions[] = '(content_id::text = ? OR lower(titel) LIKE lower(?))';
$params[] = $term;
$params[] = '%' . $term . '%';
}
$query = '
SELECT DISTINCT tbl_content.content_id
FROM campus.tbl_contentsprache
JOIN campus.tbl_content USING (content_id)
WHERE tbl_content.template_kurzbz <> ?
AND (' . implode(' OR ', $conditions) . ')
ORDER BY content_id
';
array_unshift($params, 'news');
return $this->execReadOnlyQuery($query, $params);
}
/**
* All contents eligible as children: excludes ancestors, self, and news.
* @param int $content_id the content to find children for
* @param string $sprache language for the titel subselect
* @return stdClass success with array of rows or error
*/
public function getPossibleChilds($content_id, $sprache)
{
$query = '
SELECT content_id, oe_kurzbz, template_kurzbz,
(SELECT titel FROM campus.tbl_contentsprache
WHERE sprache = ? AND content_id = tbl_content.content_id
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_content
WHERE content_id NOT IN (
WITH RECURSIVE parents(content_id, child_content_id) AS (
SELECT content_id, child_content_id FROM campus.tbl_contentchild
WHERE child_content_id = ?
UNION ALL
SELECT cc.content_id, cc.child_content_id
FROM campus.tbl_contentchild cc, parents
WHERE cc.child_content_id = parents.content_id)
SELECT content_id FROM parents GROUP BY content_id)
AND content_id <> ?
AND template_kurzbz <> ?
ORDER BY titel
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $content_id, 'news']);
}
/**
* Where a content is referenced. Returns [{table, label}] for the delete dialog.
* Tolerates missing addon/testtool schemas.
* @param int $content_id
* @return stdClass success with flat array or error
*/
public function getUsage($content_id)
{
$coreQuery = "
SELECT 'campus.tbl_infoscreen_content' AS \"table\",
infoscreen_id::text AS label
FROM campus.tbl_infoscreen_content WHERE content_id = ?
UNION ALL
SELECT 'campus.tbl_news', betreff
FROM campus.tbl_news WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_ort', ort_kurzbz
FROM public.tbl_ort WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_service', bezeichnung
FROM public.tbl_service WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_statistik', bezeichnung
FROM public.tbl_statistik WHERE content_id = ?
";
$coreParams = [$content_id, $content_id, $content_id, $content_id, $content_id];
$coreResult = $this->execReadOnlyQuery($coreQuery, $coreParams);
$rows = [];
if (!isError($coreResult) && getData($coreResult))
$rows = getData($coreResult);
// Optional schemas: testtool and addon may not exist. A failing query still writes a
// db error into the response envelope, so check for the table before querying it.
$optionalQueries = [
[
'testtool', 'tbl_ablauf_vorgaben',
"SELECT 'testtool.tbl_ablauf_vorgaben' AS \"table\",
ablauf_vorgaben_id::text AS label
FROM testtool.tbl_ablauf_vorgaben WHERE content_id = ?"
],
[
'addon', 'tbl_software',
"SELECT 'addon.tbl_software' AS \"table\",
software_id::text AS label
FROM addon.tbl_software WHERE content_id = ?"
]
];
foreach ($optionalQueries as $opt)
{
if (!$this->tableExists($opt[0], $opt[1]))
continue;
$result = $this->execReadOnlyQuery($opt[2], [$content_id]);
if (!isError($result) && getData($result))
$rows = array_merge($rows, getData($result));
}
return success($rows);
}
/**
* True if a base table exists. Guards the optional testtool and addon schemas.
* @param string $schema
* @param string $table
* @return bool
*/
private function tableExists($schema, $table)
{
$query = "
SELECT 1 AS exists
FROM information_schema.tables
WHERE table_catalog = ? AND table_schema = ? AND table_name = ?
";
return hasData($this->execReadOnlyQuery($query, [DB_NAME, $schema, $table]));
}
/**
* Returns oe_kurzbz for a content as a string.
* @param int $content_id
* @return stdClass success with string or error
*/
public function getOeKurzbz($content_id)
{
$query = 'SELECT oe_kurzbz FROM campus.tbl_content WHERE content_id = ?';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return error('Content not found');
return success($data[0]->oe_kurzbz);
}
}
@@ -11,4 +11,140 @@ class Contentchild_model extends DB_Model
$this->dbTable = 'campus.tbl_contentchild';
$this->pk = 'contentchild_id';
}
/**
* Children with titel, sorted by sort.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getChilds($content_id, $sprache)
{
$query = '
SELECT cc.contentchild_id, cc.child_content_id, cc.sort,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = cc.child_content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_contentchild cc
WHERE cc.content_id = ?
ORDER BY cc.sort, cc.contentchild_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
}
/**
* Highest sort value. Returns 0 if no row exists.
* @param int $content_id
* @return stdClass success with int or error
*/
public function getMaxSort($content_id)
{
$query = '
SELECT COALESCE(MAX(sort), 0) AS max_sort
FROM campus.tbl_contentchild
WHERE content_id = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->max_sort);
}
/**
* All descendant content_ids recursively. Returns a flat array.
* @param int $content_id
* @return stdClass success with array of ints or error
*/
public function getAllChildIds($content_id)
{
$query = '
WITH RECURSIVE childs(content_id, child_content_id) AS (
SELECT content_id, child_content_id FROM campus.tbl_contentchild WHERE content_id = ?
UNION ALL
SELECT cc.content_id, cc.child_content_id
FROM campus.tbl_contentchild cc, childs
WHERE cc.content_id = childs.child_content_id)
SELECT DISTINCT child_content_id FROM childs
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$ids = [];
if (getData($result))
{
foreach (getData($result) as $row)
$ids[] = (int) $row->child_content_id;
}
return success($ids);
}
/**
* Swap sort value with the neighbour. Direction is 'up' or 'down'.
* @param int $contentchild_id
* @param string $direction 'up' or 'down'
* @return stdClass success or error
*/
public function swapSort($contentchild_id, $direction)
{
$currentResult = $this->load($contentchild_id);
if (isError($currentResult))
return $currentResult;
$current = getData($currentResult)[0];
if ($direction === 'up')
{
$neighbourQuery = '
SELECT contentchild_id, sort
FROM campus.tbl_contentchild
WHERE content_id = ? AND sort < ?
ORDER BY sort DESC
LIMIT 1
';
}
else
{
$neighbourQuery = '
SELECT contentchild_id, sort
FROM campus.tbl_contentchild
WHERE content_id = ? AND sort > ?
ORDER BY sort ASC
LIMIT 1
';
}
$neighbourResult = $this->execReadOnlyQuery($neighbourQuery, [$current->content_id, $current->sort]);
if (isError($neighbourResult))
return $neighbourResult;
$neighbourData = getData($neighbourResult);
if (empty($neighbourData))
return error($direction === 'up' ? 'cms/bereitsGanzOben' : 'cms/bereitsGanzUnten');
$neighbour = $neighbourData[0];
// Transaction wraps the swap; the legacy does not.
$this->db->trans_start();
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$neighbour->sort, $current->contentchild_id]
);
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$current->sort, $neighbour->contentchild_id]
);
$this->db->trans_complete();
if ($this->db->trans_status() === false)
return error('Sort swap failed');
return success(true);
}
}
@@ -56,5 +56,77 @@ class Contentgruppe_model extends DB_Model
return $result;
return success(!!getData($result));
}
/**
* Groups assigned to a content, with bezeichnung.
* @param int $content_id
* @return stdClass success with array of rows or error
*/
public function getGruppen($content_id)
{
$query = '
SELECT cg.gruppe_kurzbz, g.bezeichnung
FROM campus.tbl_contentgruppe cg
JOIN public.tbl_gruppe g USING (gruppe_kurzbz)
WHERE cg.content_id = ?
ORDER BY cg.gruppe_kurzbz
';
return $this->execReadOnlyQuery($query, [$content_id]);
}
/**
* All selectable groups. Returns gruppe_kurzbz and bezeichnung.
* @return stdClass success with array of rows or error
*/
public function getAllGruppen()
{
// content_visible marks a group as selectable for content rights. Generated groups
// carry FALSE. admin.php filters the same way via getgruppe(...,$content_visible=true).
$query = '
SELECT gruppe_kurzbz, bezeichnung
FROM public.tbl_gruppe
WHERE content_visible = TRUE
ORDER BY gruppe_kurzbz
';
return $this->execReadOnlyQuery($query);
}
/**
* Groups for multiple contents in one query. Returns content_id => [gruppe_kurzbz, ...].
* @param array $contentIds array of content_id values
* @return stdClass success with associative array or error
*/
public function getGruppenForContents($contentIds)
{
if (empty($contentIds))
return success([]);
$placeholders = implode(',', array_fill(0, count($contentIds), '?'));
$query = '
SELECT content_id, gruppe_kurzbz
FROM campus.tbl_contentgruppe
WHERE content_id IN (' . $placeholders . ')
ORDER BY content_id, gruppe_kurzbz
';
$result = $this->execReadOnlyQuery($query, array_values($contentIds));
if (isError($result))
return $result;
$map = [];
if (getData($result))
{
foreach (getData($result) as $row)
{
$id = (int) $row->content_id;
if (!isset($map[$id]))
$map[$id] = [];
$map[$id][] = $row->gruppe_kurzbz;
}
}
return success($map);
}
}
@@ -11,4 +11,26 @@ class Contentlog_model extends DB_Model
$this->dbTable = 'campus.tbl_contentlog';
$this->pk = 'contentlog_id';
}
/**
* The open lock entry (ende IS NULL). Returns null if none exists.
* @param int $contentsprache_id
* @return stdClass success with row or null, or error
*/
public function getOpenLock($contentsprache_id)
{
$query = '
SELECT contentlog_id, uid, contentsprache_id, start
FROM campus.tbl_contentlog
WHERE contentsprache_id = ? AND ende IS NULL
LIMIT 1
';
$result = $this->execReadOnlyQuery($query, [$contentsprache_id]);
if (isError($result))
return $result;
$data = getData($result);
return success(empty($data) ? null : $data[0]);
}
}
@@ -38,5 +38,128 @@ class Contentsprache_model extends DB_Model
return success(!!getData($result));
}
/**
* All versions of a language, ascending by version.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getVersions($content_id, $sprache)
{
$query = '
SELECT contentsprache_id, version, sichtbar, titel,
insertamum, insertvon, updateamum, updatevon, gesperrt_uid
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
ORDER BY version ASC
';
return $this->execReadOnlyQuery($query, [$content_id, $sprache]);
}
/**
* Distinct languages for a content. Returns a flat array of strings.
* @param int $content_id
* @return stdClass success with array of strings or error
*/
public function getLanguages($content_id)
{
$query = '
SELECT DISTINCT sprache
FROM campus.tbl_contentsprache
WHERE content_id = ?
ORDER BY sprache
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$languages = [];
if (getData($result))
{
foreach (getData($result) as $row)
$languages[] = $row->sprache;
}
return success($languages);
}
/**
* Highest version number. Returns 0 if no row exists.
* @param int $content_id
* @param string $sprache
* @return stdClass success with int or error
*/
public function getMaxVersion($content_id, $sprache)
{
$query = '
SELECT COALESCE(MAX(version), 0) AS max_version
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->max_version);
}
/**
* Row count for a content+language. Used for the delete guard.
* @param int $content_id
* @param string $sprache
* @return stdClass success with int or error
*/
public function getNumberOfVersions($content_id, $sprache)
{
$query = '
SELECT COUNT(*) AS cnt
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->cnt);
}
/**
* One version, all columns. If $version is empty, returns the highest version.
* @param int $content_id
* @param string $sprache
* @param int|null $version
* @return stdClass success with single row or error
*/
public function getOne($content_id, $sprache, $version = null)
{
$params = [$content_id, $sprache];
$versionClause = '';
if (!empty($version))
{
$versionClause = ' AND version = ?';
$params[] = $version;
}
$query = '
SELECT * FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?' . $versionClause . '
ORDER BY version DESC LIMIT 1
';
$result = $this->execReadOnlyQuery($query, $params);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return error('Version not found');
return success($data[0]);
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
$includesArray = array(
'title' => 'CMS',
'axios027' => true,
'bootstrap5' => true,
'fontawesome6' => true,
'vue3' => true,
'primevue3' => true,
'tabulator6' => true,
'tinymce5' => true,
'phrases' => array('global', 'ui', 'cms'),
'customCSSs' => [
'public/css/components/verticalsplit.css',
'public/css/Cms.css'
],
'customJSs' => [
'vendor/npm-asset/primevue/tree/tree.min.js',
'vendor/npm-asset/primevue/checkbox/checkbox.min.js',
'vendor/npm-asset/primevue/textarea/textarea.min.js',
'vendor/moment/luxonjs/luxon.min.js'
],
'customJSModules' => ['public/js/apps/Cms.js']
);
$this->load->view('templates/FHC-Header', $includesArray);
?>
<div id="main">
<router-view
cms-root="<?= site_url('cms'); ?>"
auth-uid="<?= getAuthUID(); ?>"
default-language="<?= defined('DEFAULT_LANGUAGE') ? DEFAULT_LANGUAGE : 'German'; ?>"
></router-view>
</div>
<?php $this->load->view('templates/FHC-Footer', $includesArray); ?>
+74
View File
@@ -83,12 +83,86 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null))
return confirm('Möchten Sie das File wirklich löschen?');
}
/*
* ---------------------------------------------------------------------------------
* CIS4 BRIDGE -- ticket 78489, CMS admin on CI3 and Vue
* ---------------------------------------------------------------------------------
*
* WHY THIS CHANGE IS HERE AND NOT SOMEWHERE ELSE
*
* The new CMS admin (application/controllers/Cms.php, public/js/components/Cms/) uses
* TinyMCE 5. This file returns its selection through the TinyMCE 3 API, that is
* through the global object tinyMCEPopup:
*
* tinyMCEPopup.getWindowArg("window") -> the calling window
* tinyMCEPopup.getWindowArg("input") -> the target field id in that window
* tinyMCEPopup.close() -> close this window
*
* tinyMCEPopup does not exist in TinyMCE 5. It came with the plugin "inlinepopups",
* which TinyMCE 4 removed. TinyMCE 5 uses file_picker_callback instead and expects
* the caller to hand back the URL.
*
* The return code must run IN THIS window, because only this window knows which
* dms_id the editor clicked. A wrapper in the new admin cannot catch that: it sees
* the opened window, not the click inside it. A proxy controller does not help for
* the same reason. The selection happens here.
*
* WHAT WAS DELIBERATELY NOT CHANGED
*
* The ticket excludes the DMS input interface, because branches for it are open.
* This change respects that:
*
* - It only adds. The legacy branch below stays line for line and still runs
* unchanged as soon as tinyMCEPopup is present.
* - It touches exactly one JavaScript function. No PHP, no markup, no query,
* no view, no layout.
* - It changes none of the four callers of mySubmit.
*
* When these branches are merged, only this one function needs attention.
*
* WHY NO GET PARAMETER TELLS THE TWO PATHS APART
*
* A switch such as ?cis4=1 on open looks obvious, but it does not hold: the file
* browser navigates internally (search, paging, category change) and rebuilds its
* links from $_REQUEST. An extra parameter is lost on the way, and the bridge would
* be dead after the first search. The runtime check through tinyMCEPopup and
* window.opener survives every internal navigation and needs no change to the links.
*
* THE CONTRACT WITH THE NEW ADMIN
*
* message: { typ: 'fhc-dms-selection', url: 'dms.php?id=<dms_id>' }
* target: window.opener
* origin: window.location.origin (the receiver checks against it)
*
* The URL stays RELATIVE. This is mandatory, not carelessness:
* - The legacy view runs under /cms/ and resolves dms.php?id=N directly.
* - The CIS4 view replaces the string 'dms.php' in CmsLib::getContent() with
* APP_ROOT . 'cms/dms.php'.
* An absolute URL breaks both paths. Stored content must stay interchangeable
* between the two interfaces.
*
* Receiving side: public/js/components/Cms/Form/FieldWysiwyg.js
* ---------------------------------------------------------------------------------
*/
var FileBrowserDialog=
{
init: function(){
},
mySubmit : function (id) {
var URL = "dms.php?id="+id;
// CIS4 path: see the comment block above this function.
if (typeof tinyMCEPopup === 'undefined' && window.opener && !window.opener.closed) {
window.opener.postMessage({ typ: 'fhc-dms-selection', url: URL }, window.location.origin);
window.close();
return;
}
if (typeof tinyMCEPopup === 'undefined') {
alert('The calling window is no longer available.');
return;
}
var win = tinyMCEPopup.getWindowArg("window");
// insert information now
+20
View File
@@ -0,0 +1,20 @@
.cms-admin {
min-height: 0;
}
.cms-admin-tree {
width: 300px;
min-width: 200px;
max-width: 400px;
overflow-y: auto;
border-right: 1px solid var(--fhc-border, #dee2e6);
}
.cms-admin-main {
min-width: 0;
overflow: hidden;
}
.cms-admin-tab-content {
min-height: 0;
}
+217
View File
@@ -0,0 +1,217 @@
export default {
// --- CmsAdmin ---
getTree(menu, filter) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTree',
params: { menu, ...(filter ? { filter } : {}) }
};
},
getContent(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getContent',
params: { content_id }
};
},
getContentsprache(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getContentsprache',
params: { content_id, sprache, version }
};
},
getTemplates() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTemplates'
};
},
getOrganisationseinheiten() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getOrganisationseinheiten'
};
},
getSprachen() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getSprachen'
};
},
getUsage(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getUsage',
params: { content_id }
};
},
postContent(parent_content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postContent',
params: { parent_content_id }
};
},
postTranslation(content_id, sprache, version, target_sprache) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postTranslation',
params: { content_id, sprache, version, target_sprache }
};
},
postVersion(content_id, sprache) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postVersion',
params: { content_id, sprache }
};
},
putProperties(content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/putProperties',
params: { content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar }
};
},
deleteContent(content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/deleteContent',
params: { content_id }
};
},
deleteContentsprache(content_id, sprache, version) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/deleteContentsprache',
params: { content_id, sprache, version }
};
},
// --- CmsAdminInhalt ---
getFormSchema(template_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getFormSchema',
params: { template_kurzbz }
};
},
getFormData(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getFormData',
params: { content_id, sprache, version }
};
},
getLock(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getLock',
params: { content_id, sprache, version }
};
},
getVersions(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getVersions',
params: { content_id, sprache }
};
},
putFormData(content_id, sprache, version, values) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/putFormData',
params: { content_id, sprache, version, values }
};
},
postLock(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/postLock',
params: { contentsprache_id }
};
},
deleteLock(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/deleteLock',
params: { contentsprache_id }
};
},
deleteLockForced(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/deleteLockForced',
params: { contentsprache_id }
};
},
// --- CmsAdminStruktur ---
getGruppen(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getGruppen',
params: { content_id }
};
},
getAllGruppen() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getAllGruppen'
};
},
getChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getChilds',
params: { content_id, sprache }
};
},
getPossibleChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getPossibleChilds',
params: { content_id, sprache }
};
},
postGruppe(content_id, gruppe_kurzbz) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/postGruppe',
params: { content_id, gruppe_kurzbz }
};
},
deleteGruppe(content_id, gruppe_kurzbz) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/deleteGruppe',
params: { content_id, gruppe_kurzbz }
};
},
postChild(content_id, child_content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/postChild',
params: { content_id, child_content_id }
};
},
deleteChild(contentchild_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/deleteChild',
params: { contentchild_id }
};
},
putChildSort(contentchild_id, direction) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/putChildSort',
params: { contentchild_id, direction }
};
}
};
+42
View File
@@ -0,0 +1,42 @@
import CmsAdmin from "../components/Cms/CmsAdmin.js";
import PluginsPhrasen from "../plugins/Phrasen.js";
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '')
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router;
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory('/' + ciPath),
routes: [
{
name: 'index',
path: '/cms',
component: CmsAdmin,
children: [
{
name: 'content',
path: 'content/:content_id/:sprache/:version/:tab',
component: CmsAdmin
}
]
},
{ path: '/:pathMatch(.*)*', redirect: { name: 'index' } }
]
});
FhcApps.router.makeExtendable(router);
const app = Vue.createApp({
name: 'CmsApp'
});
FhcApps.makeExtendable(app);
app
.use(router)
.use(primevue.config.default, {
zIndex: {
overlay: 1100
}
})
.use(PluginsPhrasen)
.mount('#main');
+213
View File
@@ -0,0 +1,213 @@
import ApiCmsAdmin from '../../api/factory/cmsadmin.js';
import ContentTree from './Tree/ContentTree.js';
import ContentHeader from './Header/ContentHeader.js';
import Properties from './Tabs/Properties.js';
import ContentTab from './Tabs/Content.js';
import Permissions from './Tabs/Permissions.js';
import Children from './Tabs/Children.js';
import History from './Tabs/History.js';
import Delete from './Tabs/Delete.js';
const TAB_COMPONENTS = {
properties: Properties,
content: ContentTab,
permissions: Permissions,
children: Children,
history: History,
delete: Delete
};
const TABS = [
{ key: 'properties', phrase: 'cms/tabEigenschaften' },
{ key: 'content', phrase: 'cms/tabInhalt' },
{ key: 'permissions', phrase: 'cms/tabRechte' },
{ key: 'children', phrase: 'cms/tabChilds' },
{ key: 'history', phrase: 'cms/tabHistory' },
{ key: 'delete', phrase: 'cms/tabLoeschen' }
];
export default {
name: 'CmsAdmin',
components: {
'cms-tree': ContentTree,
'cms-content-header': ContentHeader,
...TAB_COMPONENTS
},
props: {
cmsRoot: String,
authUid: String,
defaultLanguage: { type: String, default: 'German' }
},
data() {
return {
contentId: null,
sprache: null,
version: null,
tab: 'properties',
contentInfo: null,
treeKey: 0
};
},
computed: {
activeTabComponent() {
return TAB_COMPONENTS[this.tab] || null;
}
},
watch: {
'$route.params': {
handler(params) {
const id = params.content_id ? Number(params.content_id) : null;
const sprache = params.sprache || null;
const version = params.version ? Number(params.version) : null;
const tab = params.tab || 'properties';
if (id !== this.contentId || sprache !== this.sprache
|| version !== this.version || tab !== this.tab) {
this.contentId = id;
this.sprache = sprache;
this.version = version;
this.tab = tab;
if (id !== null && this.contentInfo?.content_id !== id) {
this.loadContentInfo();
}
}
},
immediate: true
}
},
methods: {
loadContentInfo() {
if (this.contentId === null) {
this.contentInfo = null;
return;
}
this.$api
.call(ApiCmsAdmin.getContent(this.contentId))
.then(result => {
this.contentInfo = result.data;
});
},
selectContent(content_id) {
if (content_id === null) {
this.contentId = null;
this.sprache = null;
this.version = null;
this.tab = 'properties';
this.contentInfo = null;
this.$router.push({ name: 'index' });
return;
}
this.contentId = content_id;
this.$api
.call(ApiCmsAdmin.getContent(content_id))
.then(result => {
this.contentInfo = result.data;
this.preselectLanguageVersion();
this.pushRoute();
});
},
selectTab(tab) {
this.tab = tab;
this.pushRoute();
},
selectLanguageVersion(sprache, version) {
this.sprache = sprache;
this.version = version;
this.pushRoute();
},
reloadTree() {
this.treeKey++;
},
preselectLanguageVersion() {
if (!this.contentInfo) return;
const languages = this.contentInfo.languages || [];
const versions = this.contentInfo.versions || {};
let lang = languages.includes(this.defaultLanguage)
? this.defaultLanguage
: languages[0] || null;
let ver = null;
if (lang && versions[lang] && versions[lang].length) {
ver = Math.max(...versions[lang]);
}
this.sprache = lang;
this.version = ver;
},
pushRoute() {
if (this.contentId === null) {
this.$router.push({ name: 'index' });
return;
}
this.$router.push({
name: 'content',
params: {
content_id: this.contentId,
sprache: this.sprache,
version: this.version,
tab: this.tab
}
});
}
},
template: `
<div class="cms-admin d-flex h-100">
<div class="cms-admin-tree">
<cms-tree
:key="treeKey"
:active-content-id="contentId"
@select-content="selectContent"
@entry-created="reloadTree"
></cms-tree>
</div>
<div class="cms-admin-main flex-grow-1 d-flex flex-column" v-if="contentId !== null">
<cms-content-header
:content-id="contentId"
:sprache="sprache"
:version="version"
:contentInfo="contentInfo"
@select-language-version="selectLanguageVersion"
@reload-content-info="loadContentInfo"
@reload-tree="reloadTree"
></cms-content-header>
<ul class="nav nav-tabs px-3">
<li class="nav-item" v-for="t in tabs" :key="t.key">
<a class="nav-link"
:class="{ active: tab === t.key }"
href="#"
@click.prevent="selectTab(t.key)"
>{{ $p.t(t.phrase) }}</a>
</li>
</ul>
<div class="cms-admin-tab-content flex-grow-1 overflow-auto">
<component
:is="activeTabComponent"
:content-id="contentId"
:sprache="sprache"
:version="version"
:contentInfo="contentInfo"
@reload-content-info="loadContentInfo"
@reload-tree="reloadTree"
@select-content="selectContent"
></component>
</div>
</div>
<div class="cms-admin-main flex-grow-1 d-flex align-items-center justify-content-center text-muted" v-else>
{{ $p.t('cms/keinEintragGewaehlt') }}
</div>
</div>
`,
created() {
this.tabs = TABS;
}
};
@@ -0,0 +1,25 @@
export default {
name: 'FieldString',
props: {
modelValue: { type: String, default: '' },
label: String,
required: Boolean,
disabled: Boolean,
type: { type: String, default: 'text' }
},
emits: ['update:modelValue'],
template: `
<div class="mb-3">
<label class="form-label">
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<input
class="form-control"
:type="type"
:value="modelValue"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
`
};
@@ -0,0 +1,104 @@
export default {
name: 'FieldWysiwyg',
props: {
modelValue: { type: String, default: '' },
label: String,
required: Boolean,
disabled: Boolean
},
emits: ['update:modelValue'],
data() {
return {
editor: null,
editorId: 'cms-wysiwyg-' + Math.random().toString(36).substring(2, 11)
};
},
methods: {
getContent() {
return this.editor ? this.editor.getContent() : this.modelValue;
}
},
watch: {
modelValue(newVal) {
if (this.editor && this.editor.getContent() !== newVal) {
this.editor.setContent(newVal || '');
}
}
},
mounted() {
const vm = this;
tinymce.init({
target: this.$refs.editorArea,
height: 500,
language: 'de',
// valid_elements: '*[*]' permits every tag and attribute. Existing content
// contains audio, video and source tags. A stricter setting deletes them silently.
valid_elements: '*[*]',
forced_root_block: '',
style_formats: [
{ title: 'Contenttabelle', selector: 'table', classes: 'cmstable' },
{ title: 'Hauptcontent', block: 'div', classes: 'cmscontent' },
{ title: 'Menuebox', block: 'div', classes: 'menubox' },
{ title: 'Teambox', block: 'div', classes: 'teambox' },
{ title: 'Schatteneffekt Grafiken', selector: 'img', classes: 'screenshot_boxshadow' },
{ title: 'Tablesorter', selector: 'table',
classes: 'tablesorter tablesorter_col_0 tablesorter_sort_0' }
],
plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr',
toolbar: [
'code | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | styleselect fontsizeselect',
'pastetext | searchreplace | bullist numlist | outdent indent blockquote | undo redo | link unlink anchor image | forecolor backcolor',
'table | hr removeformat visualblocks | subscript superscript | charmap | ltr rtl | fullscreen'
],
file_picker_callback: (callback, value, meta) => {
if (vm._dmsListener) {
window.removeEventListener('message', vm._dmsListener);
}
const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php';
window.open(url, 'DMS', 'width=1400,height=850,scrollbars=1,resizable=1');
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.typ !== 'fhc-dms-selection') return;
// The relative form dms.php?id=N is mandatory. The legacy view runs under /cms/
// and resolves it directly. The CIS4 view replaces it in CmsLib::getContent
// with APP_ROOT . 'cms/dms.php'. An absolute URL breaks both paths.
callback(event.data.url);
window.removeEventListener('message', onMessage);
vm._dmsListener = null;
};
vm._dmsListener = onMessage;
window.addEventListener('message', onMessage);
},
setup: (editor) => {
vm.editor = editor;
editor.on('init', () => {
editor.setContent(vm.modelValue || '');
if (vm.disabled) editor.mode.set('readonly');
});
editor.on('change input', () => {
vm.$emit('update:modelValue', editor.getContent());
});
}
});
},
beforeUnmount() {
if (this._dmsListener) {
window.removeEventListener('message', this._dmsListener);
this._dmsListener = null;
}
if (this.editor && tinymce.get(this.editor.id)) {
tinymce.get(this.editor.id).remove();
this.editor = null;
}
},
template: `
<div class="mb-3">
<label class="form-label">
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<textarea ref="editorArea" :id="editorId"></textarea>
</div>
`
};
+72
View File
@@ -0,0 +1,72 @@
import FieldString from './FieldString.js';
import FieldWysiwyg from './FieldWysiwyg.js';
export default {
name: 'XsdForm',
components: {
'field-string': FieldString,
'field-wysiwyg': FieldWysiwyg
},
props: {
schema: Object,
modelValue: Object,
disabled: Boolean
},
emits: ['update:modelValue'],
methods: {
updateField(name, value) {
this.$emit('update:modelValue', { ...this.modelValue, [name]: value });
},
collectValues() {
const vals = { ...this.modelValue };
if (!this.schema?.fields) return vals;
for (const field of this.schema.fields) {
if (field.type === 'wysiwyg') {
const ref = this.$refs['wysiwyg-' + field.name];
const comp = Array.isArray(ref) ? ref[0] : ref;
if (comp?.getContent) vals[field.name] = comp.getContent();
}
}
return vals;
}
},
template: `
<div v-if="schema && schema.fields">
<template v-for="field in schema.fields" :key="field.name">
<field-wysiwyg
v-if="field.type === 'wysiwyg'"
:ref="'wysiwyg-' + field.name"
:modelValue="modelValue[field.name] || ''"
:label="field.name"
:required="field.required"
:disabled="disabled"
@update:modelValue="updateField(field.name, $event)"
></field-wysiwyg>
<div v-else-if="field.type === 'boolean'" class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
:checked="!!modelValue[field.name]"
:disabled="disabled"
@change="updateField(field.name, $event.target.checked)"
:id="'field-' + field.name"
>
<label class="form-check-label" :for="'field-' + field.name">
{{ field.name }}<span v-if="field.required" class="text-danger"> *</span>
</label>
</div>
</div>
<field-string
v-else
:modelValue="modelValue[field.name] || ''"
:label="field.name"
:required="field.required"
:disabled="disabled"
:type="field.type === 'date' ? 'date' : 'text'"
@update:modelValue="updateField(field.name, $event)"
></field-string>
</template>
</div>
`
};
@@ -0,0 +1,167 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import VersionSelect from './VersionSelect.js';
import LanguageSelect from './LanguageSelect.js';
export default {
name: 'CmsContentHeader',
components: {
'version-select': VersionSelect,
'sprache-select': LanguageSelect
},
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['select-language-version', 'reload-content-info', 'reload-tree'],
data() {
return {
titel: '',
allSprachen: [],
versionDetails: [],
newSprache: null
};
},
computed: {
languages() {
return this.contentInfo?.languages || [];
},
versions() {
if (!this.contentInfo?.versions || !this.sprache) return [];
return this.contentInfo.versions[this.sprache] || [];
},
availableSprachen() {
return this.allSprachen.filter(
s => !this.languages.includes(s.sprache)
);
}
},
watch: {
contentId() {
this.loadTitel();
this.loadVersionDetails();
},
sprache() {
this.loadTitel();
this.loadVersionDetails();
},
version() {
this.loadTitel();
}
},
methods: {
loadTitel() {
if (this.contentId == null || !this.sprache || !this.version) return;
this.$api
.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
.then(result => {
this.titel = result.data?.titel || '';
});
},
loadVersionDetails() {
if (this.contentId == null || !this.sprache) {
this.versionDetails = [];
return;
}
this.$api
.call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
.then(result => {
this.versionDetails = result.data || [];
});
},
loadSprachen() {
this.$api
.call(ApiCmsAdmin.getSprachen())
.then(result => {
this.allSprachen = result.data || [];
});
},
selectVersion(ver) {
this.$emit('select-language-version', this.sprache, ver);
},
selectLanguage(lang) {
const versions = this.contentInfo?.versions?.[lang] || [];
const maxVersion = versions.length ? Math.max(...versions) : 1;
this.$emit('select-language-version', lang, maxVersion);
},
createVersion() {
this.$api
.call(ApiCmsAdmin.postVersion(this.contentId, this.sprache))
.then(result => {
const newVersion = result.data.version;
this.$fhcAlert.alertInfo(this.$p.t('cms/neueVersionIstUnsichtbar'));
this.$emit('reload-content-info');
this.$emit('select-language-version', this.sprache, newVersion);
})
.catch(this.$fhcAlert.handleSystemError);
},
createTranslation() {
if (!this.newSprache) return;
this.$api
.call(ApiCmsAdmin.postTranslation(
this.contentId, this.sprache, this.version, this.newSprache
))
.then(() => {
const lang = this.newSprache;
this.newSprache = null;
this.$emit('reload-content-info');
this.$emit('select-language-version', lang, this.version);
})
.catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
this.loadSprachen();
this.loadTitel();
this.loadVersionDetails();
},
template: `
<div class="px-3 pt-2 pb-2 border-bottom" v-if="contentInfo">
<div class="mb-2 fw-bold">
{{ $p.t('cms/contentId') }}: {{ contentId }}
| {{ $p.t('cms/version') }}: {{ version }}
| {{ $p.t('cms/sprache') }}: {{ sprache }}
| {{ $p.t('cms/titel') }}: {{ titel }}
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<version-select
:versions="versions"
:version-details="versionDetails"
:version="version"
@select-version="selectVersion"
></version-select>
<sprache-select
:languages="languages"
:sprache="sprache"
@select-language="selectLanguage"
></sprache-select>
</div>
<div>
<button class="btn btn-sm btn-outline-primary"
@click="createVersion"
>{{ $p.t('cms/neueVersionAnlegen') }}</button>
</div>
<div class="d-flex align-items-center gap-1" v-if="availableSprachen.length">
<select class="form-select form-select-sm" v-model="newSprache"
style="width:auto">
<option :value="null" disabled>{{ $p.t('cms/spracheWaehlen') }}</option>
<option v-for="s in availableSprachen" :key="s.sprache"
:value="s.sprache">{{ s.bezeichnung }}</option>
</select>
<button class="btn btn-sm btn-outline-primary"
:disabled="!newSprache"
@click="createTranslation"
>{{ $p.t('cms/uebersetzungAnlegen') }}</button>
</div>
</div>
</div>
`
};
@@ -0,0 +1,20 @@
export default {
name: 'SpracheSelect',
props: {
languages: { type: Array, default: () => [] },
sprache: String
},
emits: ['select-language'],
template: `
<div class="btn-group btn-group-sm" role="group">
<button
v-for="lang in languages"
:key="lang"
type="button"
class="btn"
:class="lang === sprache ? 'btn-primary' : 'btn-outline-secondary'"
@click="$emit('select-language', lang)"
>{{ lang }}</button>
</div>
`
};
@@ -0,0 +1,38 @@
export default {
name: 'VersionSelect',
props: {
versions: { type: Array, default: () => [] },
versionDetails: { type: Array, default: () => [] },
version: Number
},
emits: ['select-version'],
computed: {
sichtbarMap() {
const map = {};
for (const v of this.versionDetails) {
map[v.version] = v.sichtbar;
}
return map;
}
},
methods: {
isSichtbar(ver) {
return this.sichtbarMap[ver] !== false;
}
},
template: `
<div class="btn-group btn-group-sm" role="group">
<button
v-for="ver in versions"
:key="ver"
type="button"
class="btn"
:class="[
ver === version ? 'btn-primary' : 'btn-outline-secondary',
{ 'opacity-50': !isSichtbar(ver) }
]"
@click="$emit('select-version', ver)"
>{{ ver }}</button>
</div>
`
};
+65
View File
@@ -0,0 +1,65 @@
export default {
name: 'CmsPreview',
props: {
contentId: Number,
sprache: String,
version: Number,
templateKurzbz: String,
sichtbar: Boolean
},
computed: {
// The preview uses the legacy renderer cms/content.php. It reads xslt_xhtml, which
// holds a complete HTML document for all eleven templates. The CIS4 route reads
// xslt_xhtml_c4, where four templates hold a whole document instead of a fragment.
previewUrl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cms/content.php?content_id=' + this.contentId
+ '&version=' + this.version
+ '&sprache=' + this.sprache
+ '&sichtbar=';
},
// The CIS4 route takes content_id only. Its Vue route is
// /CisVue/Cms/Content/:content_id, and extra segments hit the catch-all, which
// redirects to the dashboard. CIS4 picks the version itself and the language from
// the viewer profile.
cis4Url() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/CisVue/Cms/content/' + this.contentId;
},
// The redirect template navigates away with JavaScript. In an iframe this can close
// the admin page. Therefore this case uses a link and not an iframe.
isRedirect() {
return this.templateKurzbz === 'redirect';
}
},
methods: {
neuLaden() {
if (this.$refs.previewFrame) {
this.$refs.previewFrame.src = this.previewUrl + '&t=' + Date.now();
}
}
},
template: `
<div class="mt-3">
<div v-if="sichtbar === false" class="text-danger fw-bold mb-2">
{{ $p.t('cms/unsichtbarImLivesystem') }}
</div>
<div class="mb-2">
<a :href="cis4Url" target="_blank">{{ $p.t('cms/inCis4Ansehen') }}</a>
</div>
<template v-if="isRedirect">
<a :href="previewUrl" target="_blank">
{{ $p.t('cms/vorschauInEigenemFenster') }}
</a>
</template>
<template v-else>
<iframe
ref="previewFrame"
:src="previewUrl"
style="width: 100%; min-height: 500px; border: 1px solid #ccc;"
></iframe>
</template>
</div>
`
};
+179
View File
@@ -0,0 +1,179 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsChildren',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
childs: [],
possibleChilds: [],
selectedChild: ''
};
},
computed: {
// tbl_contentchild has no unique index. The server permits a duplicate assignment.
// Therefore the select list hides the children that are already assigned.
availableChilds() {
const assigned = new Set(this.childs.map(c => c.child_content_id));
return this.possibleChilds.filter(c => !assigned.has(c.content_id));
}
},
watch: {
contentId() { this.loadAll(); },
sprache() { this.loadAll(); }
},
methods: {
loadAll() {
this.loadChilds();
this.loadPossibleChilds();
},
loadChilds() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getChilds(this.contentId, this.sprache))
.then(result => { this.childs = result.data || []; })
.catch(this.$fhcAlert.handleSystemError);
},
loadPossibleChilds() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getPossibleChilds(this.contentId, this.sprache))
.then(result => {
this.possibleChilds = result.data || [];
this.selectedChild = '';
})
.catch(this.$fhcAlert.handleSystemError);
},
childLabel(c) {
const titel = c.titel || this.$p.t('cms/ohneTitel');
return titel + ' (' + c.content_id + ')';
},
async addChild() {
if (!this.selectedChild) return;
try {
await this.$api.call(
ApiCmsAdmin.postChild(this.contentId, this.selectedChild)
);
this.loadAll();
this.$emit('reload-tree');
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
async removeChild(contentchild_id) {
const confirmed = await this.$fhcAlert.confirm({
message: this.$p.t('cms/zuordnungEntfernenBestaetigen')
});
if (!confirmed) return;
try {
await this.$api.call(ApiCmsAdmin.deleteChild(contentchild_id));
this.loadAll();
this.$emit('reload-tree');
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
async moveSort(contentchild_id, direction) {
try {
await this.$api.call(
ApiCmsAdmin.putChildSort(contentchild_id, direction)
);
this.loadChilds();
this.$emit('reload-tree');
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
onChildClick(child_content_id) {
this.$emit('select-content', child_content_id);
},
isFirst(index) {
return index === 0;
},
isLast(index) {
return index === this.childs.length - 1;
}
},
mounted() {
this.loadAll();
},
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/folgendeEintraegeSindUntergeordnet') }}</h5>
<template v-if="childs.length > 0">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/sortierung') }}</th>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(child, idx) in childs" :key="child.contentchild_id">
<td class="text-nowrap">
{{ child.sort }}
<button class="btn btn-sm btn-link p-0 ms-1"
:disabled="isFirst(idx)"
@click="moveSort(child.contentchild_id, 'up')">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn btn-sm btn-link p-0 ms-1"
:disabled="isLast(idx)"
@click="moveSort(child.contentchild_id, 'down')">
<i class="fa-solid fa-arrow-down"></i>
</button>
</td>
<td>{{ child.child_content_id }}</td>
<td>
<a href="#" @click.prevent="onChildClick(child.child_content_id)">
{{ child.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeChild(child.contentchild_id)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="text-muted">
{{ $p.t('cms/keineUntereintraege') }}
</div>
<div class="d-flex gap-2 align-items-end mt-3">
<select class="form-select" v-model="selectedChild" style="max-width: 500px;">
<option value="" disabled></option>
<option v-for="c in availableChilds" :key="c.content_id"
:value="c.content_id">
{{ childLabel(c) }}
</option>
</select>
<button class="btn btn-primary" @click="addChild" :disabled="!selectedChild">
{{ $p.t('cms/hinzufuegen') }}
</button>
</div>
</div>
`
};
+148
View File
@@ -0,0 +1,148 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import XsdForm from '../Form/XsdForm.js';
import CmsPreview from '../Preview.js';
export default {
name: 'CmsContent',
components: {
'xsd-form': XsdForm,
'cms-preview': CmsPreview
},
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
schema: null,
values: {},
sperre: null,
sichtbar: true,
loading: false,
saving: false
};
},
computed: {
lockState() {
if (!this.sperre || this.sperre.gesperrt_uid === null) return 'free';
if (this.sperre.own) return 'own';
return 'foreign';
}
},
watch: {
contentId() { this.loadFormData(); },
sprache() { this.loadFormData(); },
version() { this.loadFormData(); }
},
methods: {
loadFormData() {
if (this.contentId == null || !this.sprache || !this.version) return;
this.loading = true;
Promise.all([
this.$api.call(ApiCmsAdmin.getFormData(this.contentId, this.sprache, this.version)),
this.$api.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
])
.then(([formResult, spracheResult]) => {
const d = formResult.data;
this.schema = d.schema;
this.values = d.values || {};
this.sperre = d.sperre;
this.sichtbar = !!spracheResult.data.sichtbar;
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.loading = false; });
},
lock() {
this.$api
.call(ApiCmsAdmin.postLock(this.sperre.contentsprache_id))
.then(() => { this.loadFormData(); })
.catch(this.$fhcAlert.handleSystemError);
},
// LEGACY-QUIRK: Releasing a lock releases all locks of this user, not just this page's. See Q1.
unlock() {
this.$api
.call(ApiCmsAdmin.deleteLock(this.sperre.contentsprache_id))
.then(() => { this.loadFormData(); })
.catch(this.$fhcAlert.handleSystemError);
},
forceUnlock() {
this.$api
.call(ApiCmsAdmin.deleteLockForced(this.sperre.contentsprache_id))
.then(() => { this.loadFormData(); })
.catch(this.$fhcAlert.handleSystemError);
},
save() {
const freshValues = this.$refs.xsdForm
? this.$refs.xsdForm.collectValues()
: this.values;
this.saving = true;
this.$api
.call(ApiCmsAdmin.putFormData(
this.contentId, this.sprache, this.version, freshValues
))
.then(() => {
this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
if (this.$refs.preview) this.$refs.preview.neuLaden();
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.saving = false; });
}
},
mounted() {
this.loadFormData();
},
template: `
<div class="p-3" v-if="contentId != null">
<template v-if="!loading && sperre">
<template v-if="lockState === 'free'">
<button class="btn btn-primary mb-3" @click="lock">
{{ $p.t('cms/zurBearbeitungSperren') }}
</button>
</template>
<template v-else-if="lockState === 'own'">
<div class="alert alert-warning">
{{ $p.t('cms/freigabeGibtAlleSperrenFrei') }}
</div>
<button class="btn btn-outline-secondary mb-3" @click="unlock">
{{ $p.t('cms/sperreFreigeben') }}
</button>
<xsd-form
ref="xsdForm"
:schema="schema"
v-model="values"
:disabled="false"
></xsd-form>
<button class="btn btn-primary mt-2 mb-3" @click="save" :disabled="saving">
{{ $p.t('cms/speichern') }}
</button>
</template>
<template v-else>
<div class="alert alert-info">
{{ $p.t('cms/gesperrtVonSeit', { uid: sperre.gesperrt_uid, start: sperre.start }) }}
</div>
<button v-if="sperre.may_force" class="btn btn-danger mb-3" @click="forceUnlock">
{{ $p.t('cms/freigabeErzwingen') }}
</button>
</template>
<cms-preview
ref="preview"
:content-id="contentId"
:sprache="sprache"
:version="version"
:template-kurzbz="contentInfo?.template_kurzbz"
:sichtbar="sichtbar"
></cms-preview>
</template>
</div>
`
};
+142
View File
@@ -0,0 +1,142 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsDelete',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
usages: [],
loadingUsage: true
};
},
computed: {
hasRestrict() {
return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content');
},
// Request to T07: forward defaultLanguage prop to tabs. Using 'German' as fallback.
isLastVersion() {
if (!this.contentInfo?.versions || !this.sprache) return false;
const defaultLang = 'German';
const versions = this.contentInfo.versions[this.sprache];
return this.sprache === defaultLang && versions && versions.length === 1;
},
totalVersionCount() {
if (!this.contentInfo?.versions) return 0;
return Object.values(this.contentInfo.versions)
.reduce((sum, vers) => sum + vers.length, 0);
}
},
watch: {
contentId() { this.loadUsage(); }
},
methods: {
loadUsage() {
if (this.contentId == null) return;
this.loadingUsage = true;
this.$api
.call(ApiCmsAdmin.getUsage(this.contentId))
.then(result => {
this.usages = result.data || [];
})
.finally(() => { this.loadingUsage = false; });
},
deleteVersion() {
this.$fhcAlert.confirm({
message: this.$p.t('cms/versionLoeschenBestaetigen'),
acceptLabel: this.$p.t('ui/loeschen'),
acceptClass: 'p-button-danger'
}).then(confirmed => {
if (!confirmed) return;
return this.$api.call(
ApiCmsAdmin.deleteContentsprache(this.contentId, this.sprache, this.version)
);
}).then(result => {
if (!result) return;
this.$emit('reload-content-info');
}).catch(this.$fhcAlert.handleSystemError);
},
deleteContent() {
this.$fhcAlert.confirm({
message: this.$p.t('cms/contentLoeschenBestaetigen', {
sprachen: this.contentInfo.languages.length,
versionen: this.totalVersionCount
}),
acceptLabel: this.$p.t('ui/loeschen'),
acceptClass: 'p-button-danger'
}).then(confirmed => {
if (!confirmed) return;
return this.$api.call(ApiCmsAdmin.deleteContent(this.contentId));
}).then(result => {
if (!result) return;
this.$emit('reload-tree');
this.$nextTick(() => {
this.$emit('select-content', null);
});
}).catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
this.loadUsage();
},
// Five tables prevent the deletion with ON DELETE RESTRICT. tbl_infoscreen_content
// deletes its own rows with CASCADE. The legacy code shows neither case and hides the
// error. See Q5 in the contract.
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/dieseVersionLoeschen') }}</h5>
<div v-if="isLastVersion" class="alert alert-warning mb-3">
{{ $p.t('cms/letzteVersionNichtLoeschbar') }}
</div>
<div v-else class="mb-3">
<button class="btn btn-outline-danger" @click="deleteVersion">
{{ $p.t('cms/dieseVersionLoeschen') }}
</button>
</div>
<hr>
<h5>{{ $p.t('cms/ganzenContentLoeschen') }}</h5>
<div v-if="loadingUsage" class="mb-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
</div>
<template v-else>
<div v-if="usages.length === 0" class="alert alert-info mb-3">
{{ $p.t('cms/keineVerwendungGefunden') }}
</div>
<div v-else class="mb-3">
<div class="alert alert-warning">
{{ $p.t('cms/contentWirdVerwendetIn') }}
</div>
<ul class="list-group mb-3">
<li v-for="(u, i) in usages" :key="i"
class="list-group-item d-flex justify-content-between align-items-center">
<span>{{ u.label }}</span>
<span v-if="u.table === 'campus.tbl_infoscreen_content'"
class="badge bg-danger">
{{ $p.t('cms/wirdMitgeloescht') }}
</span>
<span v-else class="badge bg-warning text-dark">
{{ $p.t('cms/verhindertLoeschen') }}
</span>
</li>
</ul>
</div>
</template>
<button class="btn btn-danger" @click="deleteContent"
:disabled="hasRestrict || loadingUsage">
{{ $p.t('cms/ganzenContentLoeschen') }}
</button>
</div>
`
};
+65
View File
@@ -0,0 +1,65 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsHistory',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
versions: []
};
},
watch: {
contentId() { this.loadVersions(); },
sprache() { this.loadVersions(); }
},
methods: {
loadVersions() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
.then(result => {
this.versions = result.data || [];
})
.catch(this.$fhcAlert.handleSystemError);
},
formatDate(dateStr) {
if (!dateStr) return '';
try {
const dt = luxon.DateTime.fromSQL(dateStr);
return dt.isValid ? dt.toFormat('dd.MM.yyyy') : dateStr;
} catch (e) {
return dateStr;
}
}
},
mounted() {
this.loadVersions();
},
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/versionen') }}</h5>
<div v-for="v in versions" :key="v.version"
class="mb-3 p-2 border rounded"
:class="{ 'border-primary border-2': v.version === version }">
<div class="fw-bold">
{{ $p.t('cms/version') }} {{ v.version }}
<span v-if="!v.sichtbar" class="badge bg-secondary ms-2">
{{ $p.t('cms/unsichtbar') }}
</span>
</div>
<div>{{ $p.t('cms/erstelltAmVon', { amum: formatDate(v.insertamum), von: v.insertvon }) }}</div>
<div v-if="v.updatevon">
{{ $p.t('cms/letzteAenderungVonAm', { von: v.updatevon, amum: formatDate(v.updateamum) }) }}
</div>
</div>
</div>
`
};
@@ -0,0 +1,132 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsPermissions',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
gruppen: [],
allGruppen: [],
selectedGruppe: '',
allGruppenLoaded: false
};
},
computed: {
availableGruppen() {
const assigned = new Set(this.gruppen.map(g => g.gruppe_kurzbz));
return this.allGruppen.filter(g => !assigned.has(g.gruppe_kurzbz));
}
},
watch: {
contentId() { this.loadGruppen(); }
},
methods: {
loadGruppen() {
if (this.contentId == null) return;
this.$api
.call(ApiCmsAdmin.getGruppen(this.contentId))
.then(result => {
this.gruppen = result.data || [];
this.selectedGruppe = '';
})
.catch(this.$fhcAlert.handleSystemError);
},
loadAllGruppen() {
if (this.allGruppenLoaded) return;
this.$api
.call(ApiCmsAdmin.getAllGruppen())
.then(result => {
this.allGruppen = result.data || [];
this.allGruppenLoaded = true;
})
.catch(this.$fhcAlert.handleSystemError);
},
async addGruppe() {
if (!this.selectedGruppe) return;
try {
await this.$api.call(
ApiCmsAdmin.postGruppe(this.contentId, this.selectedGruppe)
);
this.loadGruppen();
this.$emit('reload-tree');
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
async removeGruppe(gruppe_kurzbz) {
if (this.gruppen.length <= 1) {
const confirmed = await this.$fhcAlert.confirm({
message: this.$p.t('cms/letzteGruppeEntfernenBestaetigen')
});
if (!confirmed) return;
}
try {
await this.$api.call(
ApiCmsAdmin.deleteGruppe(this.contentId, gruppe_kurzbz)
);
this.loadGruppen();
this.$emit('reload-tree');
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
}
},
mounted() {
this.loadAllGruppen();
this.loadGruppen();
},
template: `
<div class="p-3" v-if="contentId != null">
<template v-if="gruppen.length > 0">
<h5>{{ $p.t('cms/folgendeGruppenDuerfenAnsehen') }}</h5>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/gruppe') }}</th>
<th>{{ $p.t('cms/bezeichnung') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="g in gruppen" :key="g.gruppe_kurzbz">
<td>{{ g.gruppe_kurzbz }}</td>
<td>{{ g.bezeichnung }}</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeGruppe(g.gruppe_kurzbz)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="alert alert-warning">
{{ $p.t('cms/seiteDarfVonAllenAngezeigtWerden') }}
</div>
<div class="d-flex gap-2 align-items-end mt-3">
<select class="form-select" v-model="selectedGruppe" style="max-width: 400px;">
<option value="" disabled></option>
<option v-for="g in availableGruppen" :key="g.gruppe_kurzbz"
:value="g.gruppe_kurzbz">
{{ g.gruppe_kurzbz }} — {{ g.bezeichnung }}
</option>
</select>
<button class="btn btn-primary" @click="addGruppe" :disabled="!selectedGruppe">
{{ $p.t('cms/hinzufuegen') }}
</button>
</div>
</div>
`
};
+184
View File
@@ -0,0 +1,184 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsProperties',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
titel: '',
sichtbar: false,
template_kurzbz: '',
oe_kurzbz: '',
aktiv: false,
menu_open: false,
beschreibung: '',
templates: [],
organisationseinheiten: [],
templateChanged: false,
saving: false
};
},
watch: {
contentId() { this.loadData(); },
sprache() { this.loadData(); },
version() { this.loadData(); },
contentInfo() { this.loadContentFields(); }
},
methods: {
loadData() {
this.templateChanged = false;
if (this.contentId == null || !this.sprache || !this.version) return;
this.$api
.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
.then(result => {
const d = result.data;
this.titel = d.titel || '';
this.sichtbar = !!d.sichtbar;
});
this.loadContentFields();
},
loadContentFields() {
if (!this.contentInfo) return;
this.template_kurzbz = this.contentInfo.template_kurzbz || '';
this.oe_kurzbz = this.contentInfo.oe_kurzbz || '';
this.aktiv = !!this.contentInfo.aktiv;
this.menu_open = !!this.contentInfo.menu_open;
this.beschreibung = this.contentInfo.beschreibung || '';
},
loadLookups() {
this.$api
.call(ApiCmsAdmin.getTemplates())
.then(result => { this.templates = result.data || []; });
this.$api
.call(ApiCmsAdmin.getOrganisationseinheiten())
.then(result => { this.organisationseinheiten = result.data || []; });
},
onTemplateChange() {
this.templateChanged = true;
},
oeLabel(oe) {
let label = oe.organisationseinheittyp_kurzbz + ' ' + oe.bezeichnung;
if (!oe.aktiv) label += ' (' + this.$p.t('cms/inaktiv') + ')';
return label;
},
save() {
this.saving = true;
this.$api
.call(ApiCmsAdmin.putProperties(
this.contentId, this.sprache, this.version,
this.template_kurzbz, this.oe_kurzbz,
this.aktiv, this.menu_open, this.beschreibung,
this.titel, this.sichtbar
))
.then(() => {
this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
this.$emit('reload-content-info');
this.$emit('reload-tree');
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.saving = false; });
}
},
mounted() {
this.loadLookups();
this.loadData();
},
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/giltFuerAlleSprachen') }}</h5>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/vorlage') }}</label>
<div class="col-sm-9">
<select class="form-select" v-model="template_kurzbz"
@change="onTemplateChange">
<option v-for="t in templates" :key="t.template_kurzbz"
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
</select>
<div v-if="templateChanged" class="text-danger mt-1 fw-bold">
{{ $p.t('cms/vorlageWarnung') }}
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/organisationseinheit') }}</label>
<div class="col-sm-9">
<select class="form-select" v-model="oe_kurzbz">
<option v-for="oe in organisationseinheiten" :key="oe.oe_kurzbz"
:value="oe.oe_kurzbz"
:class="{ 'text-muted': !oe.aktiv }">{{ oeLabel(oe) }}</option>
</select>
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="aktiv"
id="prop-aktiv">
<label class="form-check-label" for="prop-aktiv">
{{ $p.t('cms/aktiv') }}
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="menu_open"
id="prop-menu-open">
<label class="form-check-label" for="prop-menu-open">
{{ $p.t('cms/menuOpen') }}
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/beschreibung') }}</label>
<div class="col-sm-9">
<textarea class="form-control" rows="3" v-model="beschreibung"></textarea>
</div>
</div>
<hr>
<h5>{{ $p.t('cms/giltFuerDieseVersion') }}</h5>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/titel') }}</label>
<div class="col-sm-9">
<input type="text" class="form-control" v-model="titel" maxlength="256">
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="sichtbar"
id="prop-sichtbar">
<label class="form-check-label" for="prop-sichtbar">
{{ $p.t('cms/sichtbar') }}
</label>
</div>
</div>
</div>
<hr>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-primary" @click="save" :disabled="saving">
{{ $p.t('cms/speichern') }}
</button>
</div>
</div>
`
};
@@ -0,0 +1,200 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import TreeFilter from './TreeFilter.js';
import MenuSwitch from './MenuSwitch.js';
const TreeNode = {
name: 'CmsTreeNode',
inject: ['treeSelect', 'treeToggle'],
props: {
node: Object,
activeContentId: Number,
expandedKeys: Object,
flat: Boolean
},
computed: {
hasChildren() {
return this.node.children && this.node.children.length > 0;
},
isExpanded() {
return !!this.expandedKeys[this.node.content_id];
},
isActive() {
return this.activeContentId != null
&& this.node.content_id === this.activeContentId;
},
labelStyle() {
const s = {};
if (this.isActive) {
s.fontWeight = 'bold';
s.background = '#e8f0fe';
s.borderRadius = '3px';
s.padding = '0 4px';
}
if (!this.node.aktiv) {
s.opacity = '0.5';
}
return s;
}
},
template: `
<li class="py-1">
<span v-if="!flat && hasChildren"
style="cursor:pointer;user-select:none"
@click="treeToggle(node)">
<i class="fa-solid fa-fw"
:class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
<span v-else-if="!flat" style="display:inline-block;width:1.25em"></span>
<i v-if="node.groups && node.groups.length"
class="fa-solid fa-lock fa-sm me-1"
:title="node.groups.join(', ')"></i>
<a v-if="node.entitled"
href="#"
class="text-decoration-none"
:style="labelStyle"
@click.prevent="treeSelect(node)">{{ node.titel }} ({{ node.content_id }})</a>
<span v-else :style="!node.aktiv ? 'opacity:0.5' : ''">{{ node.titel }} ({{ node.content_id }})</span>
<ul v-if="!flat && isExpanded && hasChildren" class="list-unstyled ms-3">
<cms-tree-node
v-for="child in node.children"
:key="child.content_id"
:node="child"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="flat"
></cms-tree-node>
</ul>
</li>
`
};
export default {
name: 'CmsTree',
components: {
'cms-tree-node': TreeNode,
'tree-filter': TreeFilter,
'menu-switch': MenuSwitch
},
props: {
activeContentId: Number
},
emits: ['select-content', 'entry-created'],
data() {
const stored = localStorage.getItem('cms/menu');
return {
menu: (stored === 'content' || stored === 'news') ? stored : 'content',
filter: '',
nodes: [],
loading: false,
expandedKeys: {}
};
},
provide() {
return {
treeSelect: (node) => { this.selectNode(node); },
treeToggle: (node) => { this.toggleNode(node); }
};
},
watch: {
menu() {
this.loadTree();
},
activeContentId(id) {
if (id != null) {
this.expandPathTo(id);
}
}
},
methods: {
loadTree() {
this.loading = true;
this.$api
.call(ApiCmsAdmin.getTree(this.menu, this.filter))
.then(result => {
this.nodes = result.data;
this.expandedKeys = {};
if (this.activeContentId != null) {
this.expandPathTo(this.activeContentId);
}
this.loading = false;
});
},
expandPathTo(targetId) {
const search = (nodes) => {
for (const node of nodes) {
if (node.content_id === targetId) return true;
if (node.children && node.children.length) {
if (search(node.children)) {
this.expandedKeys[node.content_id] = true;
return true;
}
}
}
return false;
};
search(this.nodes);
},
toggleNode(node) {
if (this.expandedKeys[node.content_id]) {
delete this.expandedKeys[node.content_id];
} else {
this.expandedKeys[node.content_id] = true;
}
},
selectNode(node) {
if (node.entitled) {
this.$emit('select-content', node.content_id);
}
},
onFilter(text) {
this.filter = text;
this.loadTree();
},
createEntry(parentId) {
this.$api
.call(ApiCmsAdmin.postContent(parentId))
.then(result => {
const newId = result.data.content_id;
this.$emit('entry-created');
this.$emit('select-content', newId);
});
}
},
mounted() {
this.loadTree();
},
template: `
<div class="p-2">
<menu-switch v-model="menu"></menu-switch>
<tree-filter class="mt-2" @filter="onFilter"></tree-filter>
<div class="mt-2 mb-2">
<button class="btn btn-sm btn-outline-secondary me-1"
@click="createEntry(null)">
{{ $p.t('cms/neuenEintragHinzufuegen') }}
</button>
<button class="btn btn-sm btn-outline-secondary"
:disabled="activeContentId == null"
@click="createEntry(activeContentId)">
{{ $p.t('cms/neuenChildEintragHinzufuegen') }}
</button>
</div>
<div v-if="loading" class="text-center text-muted py-3">
<i class="fa-solid fa-spinner fa-spin"></i>
</div>
<div v-else-if="nodes?.length === 0" class="text-muted py-2">
{{ filter ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
</div>
<ul v-else class="list-unstyled mb-0">
<cms-tree-node
v-for="node in nodes"
:key="node.content_id"
:node="node"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="menu === 'news'"
></cms-tree-node>
</ul>
</div>
`
};
@@ -0,0 +1,35 @@
export default {
name: 'CmsMenuSwitch',
props: {
modelValue: { type: String, default: 'content' }
},
emits: ['update:modelValue'],
methods: {
select(value) {
localStorage.setItem('cms/menu', value);
this.$emit('update:modelValue', value);
}
},
mounted() {
// The legacy code keeps this choice in $_SESSION['cms/menu']. The Vue client uses
// localStorage. The effect for the editor is the same.
const stored = localStorage.getItem('cms/menu');
if ((stored === 'content' || stored === 'news') && stored !== this.modelValue) {
this.$emit('update:modelValue', stored);
}
},
template: `
<div class="btn-group btn-group-sm w-100" role="group">
<button type="button" class="btn"
:class="modelValue === 'content' ? 'btn-primary' : 'btn-outline-secondary'"
@click="select('content')">
{{ $p.t('cms/menuContent') }}
</button>
<button type="button" class="btn"
:class="modelValue === 'news' ? 'btn-primary' : 'btn-outline-secondary'"
@click="select('news')">
{{ $p.t('cms/menuNews') }}
</button>
</div>
`
};
@@ -0,0 +1,26 @@
export default {
name: 'CmsTreeFilter',
emits: ['filter'],
data() {
return {
text: ''
};
},
methods: {
submit() {
this.$emit('filter', this.text);
}
},
template: `
<div class="input-group input-group-sm">
<input type="text"
class="form-control"
:placeholder="$p.t('cms/filterPlatzhalter')"
v-model="text"
@keydown.enter="submit">
<button class="btn btn-outline-secondary" type="button" @click="submit">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
`
};
File diff suppressed because it is too large Load Diff