diff --git a/application/config/cms.php b/application/config/cms.php new file mode 100644 index 000000000..2e7ad46c8 --- /dev/null +++ b/application/config/cms.php @@ -0,0 +1,21 @@ + 0. +$config['lock_ttl_hours'] = 24; + +// Switches the click statistics off completely. With false the admin tab stays hidden and +// the endpoint refuses every request, whatever the rights of the caller are. +$config['clickstats_enabled'] = true; + +// Default period of the click statistics in months, when the caller sends none. +$config['clickstats_months'] = 12; + +// Length of the ranking in the click statistics tab. +$config['clickstats_limit'] = 100; diff --git a/application/config/routes.php b/application/config/routes.php index bb0b0f65e..743362b27 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -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'; diff --git a/application/controllers/CisVue/Cms.php b/application/controllers/CisVue/Cms.php index aa07b919f..b3505007c 100644 --- a/application/controllers/CisVue/Cms.php +++ b/application/controllers/CisVue/Cms.php @@ -44,19 +44,22 @@ class Cms extends Auth_Controller * * @return void */ - public function content($content_id, $version = null, $sprache = null, $sichtbar = true) + public function content($content_id = null, $version = null, $sprache = null, $sichtbar = true) { - // return early if the content_id for the content is missing - if (!isset($content_id)) - $this->terminateWithError("content_id is missing"); + if (!is_numeric($content_id)) + show_404(); $content = $this->ContentModel->load($content_id); if (isError($content)) - $this->terminateWithError(getError($content)); + { + show_error('The content could not be loaded'); + } $content = getData($content); - if (NULL === $content) - $this->terminateWithError("Content not found"); + + if (NULL === $content) { + show_404(); + } $content = current($content); diff --git a/application/controllers/Cms.php b/application/controllers/Cms.php new file mode 100644 index 000000000..353832231 --- /dev/null +++ b/application/controllers/Cms.php @@ -0,0 +1,23 @@ + 'basis/cms:r']); + $this->load->library('PermissionLib'); + $this->config->load('cms'); + $this->loadPhrases(['global', 'ui', 'cms']); + } + + public function index() + { + // decide whether the click statistics tab is shown at all. + $this->load->view('Cms.php', [ + 'clickstats' => $this->config->item('clickstats_enabled') + && $this->permissionlib->isBerechtigt('admin') + ]); + } +} diff --git a/application/controllers/api/frontend/v1/Cms.php b/application/controllers/api/frontend/v1/Cms.php index b8937a1f4..0897e1026 100644 --- a/application/controllers/api/frontend/v1/Cms.php +++ b/application/controllers/api/frontend/v1/Cms.php @@ -25,6 +25,10 @@ if (! defined('BASEPATH')) exit('No direct script access allowed'); */ class Cms extends FHCAPI_Controller { + // Upper bound for the tiefe of oe-personen and dms-liste. A hierarchy this deep does + // not occur, and the cap keeps one marker from walking a whole tree. + const MAX_TIEFE = 10; + /** * Object initialization @@ -39,6 +43,11 @@ class Cms extends FHCAPI_Controller 'news' => self::PERM_LOGGED, 'getNewsRowCount' => self::PERM_LOGGED, 'getNews' => self::PERM_LOGGED, + 'getOePersonen' => self::PERM_LOGGED, + 'getDmsKategorie' => self::PERM_LOGGED, + 'getContentChilds' => self::PERM_LOGGED, + 'getPerson' => self::PERM_LOGGED, + 'getDmsDokumente' => self::PERM_LOGGED, ]); @@ -55,11 +64,7 @@ class Cms extends FHCAPI_Controller ]); } - - //------------------------------------------------------------------------------------------------------------------ - // Private methods - - + //------------------------------------------------------------------------------------------------------------------ // Public methods @@ -80,7 +85,10 @@ class Cms extends FHCAPI_Controller $sprache = $this->input->get("sprache",TRUE); $sichtbar = $this->input->get("sichtbar",TRUE); - $content = $this->cmslib->getContent($content_id, $version, $sprache, $sichtbar); + // cms admin preview renders a content without counting a view + $preview = $this->input->get("preview",TRUE); + + $content = $this->cmslib->getContent($content_id, $version, $sprache, $sichtbar, !$preview); $content = $this->getDataOrTerminateWithError($content); $this->terminateWithSuccess($content); @@ -203,5 +211,331 @@ class Cms extends FHCAPI_Controller } + + /** + * Lists the people assigned to an organisation unit. + * Used by the CMS content component "oe-personen". + */ + public function getOePersonen() + { + // form validation + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('oe_kurzbz', 'Organisationseinheit', 'required'); + if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel'); + $this->load->model('organisation/Organisationseinheit_model', 'OrganisationseinheitModel'); + + $oe_kurzbz = $this->input->get('oe_kurzbz', TRUE); + $mitFoto = $this->input->get('foto', TRUE) ? true : false; + + // tiefe 0 asks about the one unit. Deeper takes the sub units with it. + $oeResult = $this->OrganisationseinheitModel->getMitUntereinheiten( + $oe_kurzbz, $this->_tiefe() + ); + $oeRows = $this->getDataOrTerminateWithError($oeResult) ?: array(); + + $uids = array(); + $funktionen = array(); + + foreach ($oeRows as $oe) + { + // 'oezuordnung' is the function that assigns an employee to an organisation + // unit. The model already filters datum_von and datum_bis. + $rows = $this->BenutzerfunktionModel->getBenutzerFunktionenDetailed( + 'oezuordnung', $oe->oe_kurzbz + ); + $rows = $this->getDataOrTerminateWithError($rows) ?: array(); + + foreach ($rows as $row) + { + // The model returns the aktiv flag but does not filter on it. + if (!$row->aktiv) + continue; + + // One person can hold the assignment in several units of the walk. The + // first one wins, so the list keeps one block per person. + if (isset($funktionen[$row->uid])) + continue; + + $uids[] = $row->uid; + // tbl_benutzerfunktion.bezeichnung is the free label of one assignment, + // for example "Bibliothekarin". tbl_funktion.beschreibung is the generic + // name. + $funktionen[$row->uid] = $row->bezeichnung ?: $row->beschreibung; + } + } + + $this->terminateWithSuccess($this->_cmsPersonen($uids, $funktionen, $mitFoto)); + } + + /** + * Contact data of one person. + * Used by the CMS content component "person-block". + */ + public function getPerson() + { + // form validation + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('uid', 'UID', 'required'); + if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $uid = $this->input->get('uid', TRUE); + $mitFoto = $this->input->get('foto', TRUE) ? true : false; + + // The function label comes from the marker, not from the database. The component + // passes it through as a property. + $personen = $this->_cmsPersonen(array($uid), array(), $mitFoto); + + $this->terminateWithSuccess($personen ? current($personen) : null); + } + + /** + * Lists the published documents of one DMS category. + * Used by the CMS content component "dms-liste". + */ + public function getDmsKategorie() + { + // form validation + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required'); + if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $kategorie_kurzbz = $this->input->get('kategorie_kurzbz', TRUE); + + $erlaubt = $this->_dmsKategorieErlaubt($kategorie_kurzbz); + + if ($erlaubt === null) + $this->terminateWithError('DMS category not found', self::ERROR_TYPE_404); + + // a missing entitlement answers with success and an empty list, not with + // an error. The component sits inside a public content page. An error would raise a + // toast for every reader who is simply not in the group. + $dokumente = array(); + if ($erlaubt) + { + // tiefe 0 asks about the one category. Deeper takes the sub categories with it. + $katResult = $this->DmsModel->getMitUnterkategorien( + $kategorie_kurzbz, $this->_tiefe() + ); + $kategorien = $this->getDataOrTerminateWithError($katResult) ?: array(); + + foreach ($kategorien as $kat) + { + // Every category carries its own group rule, so a sub category can be + // closed while the parent is open. Ask for each one instead of letting the + // entitlement of the root stand for the whole walk. The root is already + // answered above. + if ($kat->kategorie_kurzbz !== $kategorie_kurzbz + && $this->_dmsKategorieErlaubt($kat->kategorie_kurzbz) !== true) + continue; + + $rows = $this->DmsModel->getKategorieDokumente($kat->kategorie_kurzbz); + $rows = $this->getDataOrTerminateWithError($rows) ?: array(); + + foreach ($rows as $row) + $dokumente[] = $row; + } + } + + $this->terminateWithSuccess(array( + 'zugriff' => $erlaubt, + 'dokumente' => $dokumente + )); + } + + /** + * Lists named documents, in the order the caller asked for them. + * Used by the CMS content component "dms-dokumente". + */ + public function getDmsDokumente() + { + // form validation + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('dms_ids', 'DMS-IDs', 'required'); + if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $this->load->model('content/Dms_model', 'DmsModel'); + + $ids = array(); + foreach (explode(',', $this->input->get('dms_ids', TRUE)) as $teil) + { + $teil = trim($teil); + if (is_numeric($teil)) + $ids[] = (int) $teil; + } + + // An unbounded list would let one marker fetch the whole DMS. + $ids = array_slice(array_unique($ids), 0, 50); + + $dokumente = $this->DmsModel->getDokumenteByIds($ids); + $dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array(); + + // A hand picked list holds few categories, so deciding per category is cheap and + // reuses the rule of getDmsKategorie(). + $erlaubte = array(); + $nachId = array(); + foreach ($dokumente as $dokument) + { + if (!array_key_exists($dokument->kategorie_kurzbz, $erlaubte)) + $erlaubte[$dokument->kategorie_kurzbz] = $this->_dmsKategorieErlaubt($dokument->kategorie_kurzbz); + + if ($erlaubte[$dokument->kategorie_kurzbz] === true) + $nachId[$dokument->dms_id] = $dokument; + } + + // The editor chose the order. Keep it. + $sortiert = array(); + foreach ($ids as $id) + { + if (isset($nachId[$id])) + $sortiert[] = $nachId[$id]; + } + + $this->terminateWithSuccess($sortiert); + } + + /** + * Lists the child pages of a content, as a reader may see them. + * Used by the CMS content component "contentchild-menu". + */ + public function getContentChilds() + { + // form validation + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural_no_zero'); + if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $this->load->model('content/Contentchild_model', 'ContentchildModel'); + + $content_id = $this->input->get('content_id', TRUE); + $sprache = $this->input->get('sprache', TRUE); + if (!$sprache) + $sprache = getUserLanguage(); + + $childs = $this->ContentchildModel->getChildsForReader($content_id, $sprache, getAuthUID()); + + $this->terminateWithSuccess($this->getDataOrTerminateWithError($childs) ?: array()); + } + + //------------------------------------------------------------------------------------------------------------------ + // Private methods + + /** + * Builds the shape both person content components render. + * + * The assembly follows Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the + * same in the news extras and on a content page. + * + * @param array $uids in the order the result should keep + * @param array $funktionen uid => function label, may be empty + * @param boolean $mitFoto + * @return array + */ + private function _cmsPersonen($uids, $funktionen, $mitFoto) + { + if (empty($uids)) + return array(); + + $this->load->model('person/Person_model', 'PersonModel'); + + $kontakt = $this->PersonModel->getCmsKontakt($uids, $mitFoto); + $kontakt = $this->getDataOrTerminateWithError($kontakt) ?: array(); + + $nachUid = array(); + foreach ($kontakt as $row) + $nachUid[$row->uid] = $row; + + $personen = array(); + + // Loops over $uids, not over the result, so the caller keeps its order. + foreach ($uids as $uid) + { + if (!isset($nachUid[$uid])) + continue; + + $row = $nachUid[$uid]; + + $name = trim(implode(' ', array_filter(array( + $row->titelpre, $row->vorname, $row->nachname, $row->titelpost + )))); + + $telefon = ''; + if ($row->telefonklappe) + $telefon = trim(($row->kontakt ?: '') . ' - ' . $row->telefonklappe); + + $email = ''; + if (defined('DOMAIN')) + $email = ($row->alias ?: $row->uid) . '@' . DOMAIN; + + $personen[] = array( + 'uid' => $row->uid, + 'name' => $name, + 'funktion' => isset($funktionen[$uid]) ? $funktionen[$uid] : '', + 'telefon' => $telefon, + 'email' => $email, + 'ort' => $row->planbezeichnung, + 'foto' => $row->foto + ); + } + + return $personen; + } + + /** + * Decides whether the current user may see a DMS category. + * + * Same rule as cms/dms.php: an unlocked category is open to everyone. A locked one + * needs a granted group, or the category permission together with basis/dms. + * + * @param string $kategorie_kurzbz + * @return boolean|null null when the category does not exist + */ + /** + * Reads the tiefe parameter of a content component. 0 keeps the component to the unit + * or category it names, which is what a marker without the attribute means. + * Capped, because the depth drives a recursive walk and one query per level found. + * + * @return int + */ + private function _tiefe() + { + $tiefe = (int) $this->input->get('tiefe', TRUE); + + if ($tiefe < 0) + return 0; + + return min($tiefe, self::MAX_TIEFE); + } + + private function _dmsKategorieErlaubt($kategorie_kurzbz) + { + $this->load->library('PermissionLib'); + $this->load->model('content/Dms_model', 'DmsModel'); + + $zugriff = $this->DmsModel->getKategorieZugriff($kategorie_kurzbz, getAuthUID()); + $zugriff = $this->getDataOrTerminateWithError($zugriff); + + if (empty($zugriff)) + return null; + + $kategorie = current($zugriff); + + if (!$kategorie->gesperrt || $kategorie->in_gruppe) + return true; + + $erlaubt = $this->permissionlib->isBerechtigt('basis/dms'); + + if ($erlaubt && $kategorie->berechtigung_kurzbz != '') + $erlaubt = $this->permissionlib->isBerechtigt($kategorie->berechtigung_kurzbz); + + return $erlaubt; + } + } diff --git a/application/controllers/api/frontend/v1/CmsAdmin.php b/application/controllers/api/frontend/v1/CmsAdmin.php new file mode 100644 index 000000000..c05df8419 --- /dev/null +++ b/application/controllers/api/frontend/v1/CmsAdmin.php @@ -0,0 +1,760 @@ + ['basis/cms:r'], + 'getSubtree' => ['basis/cms:r'], + 'getContent' => ['basis/cms:r'], + 'getContentsprache' => ['basis/cms:r'], + 'getTemplates' => ['basis/cms:r'], + 'getOrganisationseinheiten' => ['basis/cms:r'], + 'getSprachen' => ['basis/cms:r'], + 'getDmsKategorien' => ['basis/cms:r'], + 'getMitarbeiter' => ['basis/cms:r'], + 'getDmsKategorieDokumente' => ['basis/cms:r'], + 'getUsage' => ['basis/cms:r'], + 'getTreeMeta' => ['basis/cms:r'], + 'getAncestors' => ['basis/cms:r'], + 'getClickCount' => ['basis/cms:r'], + 'getClickRanking' => ['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); + } + + // One subtree in the shape of a getTree root node. Lets the client replace a branch + // instead of rebuilding the whole tree after a change. + // The visited map starts empty here, so a node with several parents can show up twice + // until the next full reload. Rebuilding the whole tree on every change costs more. + public function getSubtree() + { + $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 = (int) $this->input->get('content_id', TRUE); + + $entitledResult = $this->cmsadminlib->getEntitledOe(); + $entitledOe = isError($entitledResult) ? [] : getData($entitledResult); + + $visited = []; + $node = $this->buildTreeNode($content_id, 0, $visited); + + // Deleted meanwhile. The client drops the branch. + if ($node === null) + $this->terminateWithSuccess(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); + + $this->terminateWithSuccess($this->pruneUnentitled($node)); + } + + 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($this->p->t('cms', 'keineBerechtigungFuerDiesenEintrag')); + + $this->ContentModel->resetQuery(); + $contentResult = $this->ContentModel->load($content_id); + $contentData = $this->getDataOrTerminateWithError($contentResult); + if (empty($contentData)) + $this->terminateWithError($this->p->t('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; + + // Tells the CIS4 preview whether this template renders there at all. + $cis4Result = $this->TemplateModel->hasCis4Stylesheet($content->template_kurzbz); + $result->has_cis4_stylesheet = !isError($cis4Result) && getData($cis4Result); + + $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($this->p->t('cms', 'versionNichtGefunden')); + + $data = getData($result); + $data->sichtbar = ($data->sichtbar === 't' || $data->sichtbar === true); + + $this->terminateWithSuccess($data); + } + + // The tree lists a search hit out of its place, so a generic title carries no context. + // This answers where each one sits. One walk for the whole list, not one per node. + const MAX_ANCESTOR_TIEFE = 12; + + /** + * Ancestors of the given contents, keyed by content_id and ordered from the top down. + */ + public function getAncestors() + { + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('content_ids', 'Content IDs', 'required'); + if ($this->form_validation->run() == FALSE) + $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $ids = []; + foreach (explode(',', $this->input->get('content_ids', TRUE)) as $teil) + { + $teil = trim($teil); + if (is_numeric($teil)) + $ids[] = (int) $teil; + } + $ids = array_values(array_unique($ids)); + + if (empty($ids)) + $this->terminateWithSuccess(new stdClass()); + + $result = $this->ContentchildModel->getAncestors( + $ids, self::MAX_ANCESTOR_TIEFE, DEFAULT_LANGUAGE + ); + $rows = $this->getDataOrTerminateWithError($result); + + // Same rule as the tree: an entry of an organisational unit the editor is not + // entitled for stays readable but cannot be opened. + $entitledResult = $this->cmsadminlib->getEntitledOe(); + $entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? []; + + // The query orders by the distance, farthest first, so appending keeps the path + // reading from the top down. + $pfade = []; + foreach ((array) $rows as $row) + { + $start = (int) $row->start_id; + + if (!isset($pfade[$start])) + $pfade[$start] = []; + + $pfade[$start][] = [ + 'content_id' => (int) $row->content_id, + 'titel' => $row->titel, + 'tiefe' => (int) $row->tiefe, + 'entitled' => in_array($row->oe_kurzbz, $entitledOe) + ]; + } + + $this->terminateWithSuccess($pfade ?: new stdClass()); + } + + /** + * Per content metadata for the tree filter, keyed by content_id. + * One query for the whole tree, so the client filters and sorts without a round trip. + */ + public function getTreeMeta() + { + $result = $this->ContentModel->getTreeMeta(getAuthUID(), DEFAULT_LANGUAGE); + $rows = $this->getDataOrTerminateWithError($result); + + $meta = []; + foreach ((array) $rows as $row) + { + $meta[(int) $row->content_id] = [ + 'childcount' => (int) $row->childcount, + 'insertamum' => $row->insertamum, + 'updateamum' => $row->updateamum, + 'mine' => ($row->mine === 't' || $row->mine === true), + 'contentlength' => (int) $row->contentlength + ]; + } + + $this->terminateWithSuccess($meta); + } + + /** + * Two gates guard every click statistics request: the config flag switches the feature + * off for everybody, the admin right decides who may read the numbers. + */ + private function checkClickstatsAccess() + { + $this->config->load('cms'); + + if (!$this->config->item('clickstats_enabled')) + $this->terminateWithError($this->p->t('cms', 'klickstatistikDeaktiviert')); + + if (!$this->permissionlib->isBerechtigt('admin')) + $this->terminateWithError($this->p->t('cms', 'keineBerechtigung')); + } + + /** + * Period of a click statistics request. 0 counts the whole log. + * Call it after checkClickstatsAccess, which loads the config. + * @return array months and the start date, null for the whole log + */ + private function clickstatsPeriod() + { + $months = $this->input->get('months'); + if ($months === null || $months === '') + $months = $this->config->item('clickstats_months'); + $months = (int) $months; + + $since = ($months > 0) + ? date('Y-m-d H:i:s', strtotime('-' . $months . ' months')) + : null; + + return [$months, $since]; + } + + /** + * Views of one content. One counting query, so the tab loads it right away. + * Empty if LOG_CONTENT is off. + */ + public function getClickCount() + { + $this->checkClickstatsAccess(); + + $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('months', 'Monate', 'is_natural'); + if ($this->form_validation->run() == FALSE) + $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $this->load->model('system/Webservicelog_model', 'WebservicelogModel'); + + list($months, $since) = $this->clickstatsPeriod(); + + $result = $this->WebservicelogModel->getContentClickCount( + (int) $this->input->get('content_id', TRUE), $since + ); + + $this->terminateWithSuccess([ + 'since' => $since, + 'months' => $months, + 'own' => $this->getDataOrTerminateWithError($result) + ]); + } + + /** + * The ranking of the most viewed contents. It groups the whole log and resolves a + * title per row, which takes seconds, so the tab fetches it only on request. + * Empty if LOG_CONTENT is off. + */ + public function getClickRanking() + { + $this->checkClickstatsAccess(); + + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('months', 'Monate', 'is_natural'); + if ($this->form_validation->run() == FALSE) + $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $this->load->model('system/Webservicelog_model', 'WebservicelogModel'); + + list($months, $since) = $this->clickstatsPeriod(); + + $sprache = $this->input->get('sprache', TRUE); + if (empty($sprache)) + $sprache = DEFAULT_LANGUAGE; + + $result = $this->WebservicelogModel->getContentClickCounts( + $since, $sprache, (int) $this->config->item('clickstats_limit') + ); + $rows = $this->getDataOrTerminateWithError($result); + + $ranked = []; + foreach ((array) $rows as $row) + { + $ranked[] = [ + 'content_id' => (int) $row->content_id, + 'titel' => $row->titel, + 'hits' => (int) $row->hits + ]; + } + + $this->terminateWithSuccess([ + 'since' => $since, + 'months' => $months, + 'ranked' => $ranked + ]); + } + + 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(['content' => true]); + $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($this->p->t('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($this->p->t('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($this->p->t('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($this->p->t('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($this->p->t('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($this->p->t('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; + } + + /** + * Lists the DMS categories for the contentcomponent insert dialog. + */ + public function getDmsKategorien() + { + $this->load->model('content/Dms_model', 'DmsModel'); + + $result = $this->DmsModel->getKategorien(); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } + + + /** + * Lists active staff for the contentcomponent insert dialog. + * Names only. See Mitarbeiter_model::getAktivesPersonalNamen(). + */ + public function getMitarbeiter() + { + $this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel'); + + $result = $this->MitarbeiterModel->getAktivesPersonalNamen(); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } + + + /** + * Lists the documents of one DMS category for the contentcomponent insert dialog. + */ + public function getDmsKategorieDokumente() + { + $this->load->library('form_validation'); + $this->form_validation->set_data($_GET); + $this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required'); + if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array()); + + $this->load->model('content/Dms_model', 'DmsModel'); + + $result = $this->DmsModel->getKategorieDokumenteAlle($this->input->get('kategorie_kurzbz', TRUE)); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result) ?: array()); + } + +} diff --git a/application/controllers/api/frontend/v1/CmsAdminInhalt.php b/application/controllers/api/frontend/v1/CmsAdminInhalt.php new file mode 100644 index 000000000..8b302062b --- /dev/null +++ b/application/controllers/api/frontend/v1/CmsAdminInhalt.php @@ -0,0 +1,195 @@ + ['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($this->p->t('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($this->p->t('cms', 'contentNichtGefunden')); + + $this->TemplateModel->resetQuery(); + $templateResult = $this->TemplateModel->load($contentData[0]->template_kurzbz); + $templateData = $this->getDataOrTerminateWithError($templateResult); + if (empty($templateData)) + $this->terminateWithError($this->p->t('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() + { + $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 = []; + + $lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version); + $lock = $this->getDataOrTerminateWithError($lockResult); + if (!$lock['own']) + $this->terminateWithError($this->p->t('cms', 'speichernNurMitSperre')); + + $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($this->p->t('cms', 'keineBerechtigungFuerDieseAktion')); + + $contentsprache_id = $this->input->post('contentsprache_id'); + + $result = $this->cmsadminlib->lock($contentsprache_id); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } + + public function deleteLock() + { + if (!$this->permissionlib->isBerechtigt('basis/cms', 'u')) + $this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion')); + + $contentsprache_id = $this->input->post('contentsprache_id'); + + $result = $this->cmsadminlib->releaseOwnLock($contentsprache_id); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } + + public function deleteLockForced() + { + if (!$this->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su')) + $this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion')); + + $contentsprache_id = $this->input->post('contentsprache_id'); + + $result = $this->cmsadminlib->forceRelease($contentsprache_id); + $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + } +} diff --git a/application/controllers/api/frontend/v1/CmsAdminStruktur.php b/application/controllers/api/frontend/v1/CmsAdminStruktur.php new file mode 100644 index 000000000..0145c2d5e --- /dev/null +++ b/application/controllers/api/frontend/v1/CmsAdminStruktur.php @@ -0,0 +1,265 @@ + ['basis/cms:r'], + 'getAllGruppen' => ['basis/cms:r'], + 'getChilds' => ['basis/cms:r'], + 'getParents' => ['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'], + 'putChildOrder' => ['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 getParents() + { + $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->getParents($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($this->p->t('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($this->p->t('cms', 'gruppeBereitsZugeordnet')); + } + + $this->terminateWithSuccess(true); + } + + public function deleteGruppe() + { + if (!$this->permissionlib->isBerechtigt('basis/cms', 'u')) + $this->terminateWithError($this->p->t('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($this->p->t('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; + + $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($this->p->t('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($this->p->t('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()); + + $direction = $this->input->post('direction'); + + $result = $this->ContentchildModel->swapSort( + $this->input->post('contentchild_id'), + $direction + ); + + if (isError($result)) + { + // The child already sits at the end it was moved towards. That is a normal + // answer, not a failure, and the direction decides the wording. + if (getError($result) === Contentchild_model::NO_NEIGHBOUR) + { + $this->terminateWithError($this->p->t('cms', + $direction === 'up' ? 'bereitsGanzOben' : 'bereitsGanzUnten')); + } + + $this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen')); + } + + $this->terminateWithSuccess(true); + } + + // Writes the whole order at once. The drag and drop moves a child over any distance, + // and putChildSort would need one request per position. + public function putChildOrder() + { + if (!$this->permissionlib->isBerechtigt('basis/cms', 'u')) + $this->terminateWithError($this->p->t('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()); + + $ids = $this->input->post('contentchild_ids'); + + if (is_string($ids)) + $ids = json_decode($ids, true); + + if (!is_array($ids) || empty($ids)) + $this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen')); + + $result = $this->ContentchildModel->setSortOrder( + $this->input->post('content_id'), + $ids + ); + + if (isError($result)) + $this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen')); + + $this->terminateWithSuccess(true); + } +} diff --git a/application/libraries/CmsAdminLib.php b/application/libraries/CmsAdminLib.php new file mode 100644 index 000000000..ca0f74cd3 --- /dev/null +++ b/application/libraries/CmsAdminLib.php @@ -0,0 +1,563 @@ +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); + } + + /** + * @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($this->ci->p->t('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($this->ci->p->t('cms', 'keineVorlageVorhanden')); + $template = $templates[0]; + + $oeResult = $this->getDefaultOe(); + if (isError($oeResult)) + return $oeResult; + $oe = getData($oeResult); + + $titel = $this->ci->p->t('cms', 'neuerEintrag'); + + $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' => '', + '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($this->ci->p->t('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($this->ci->p->t('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 + '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 + 'reviewvon' => null, + 'reviewamum' => null, + 'gesperrt_uid' => null, + 'insertamum' => $now, + 'insertvon' => $uid, + 'updateamum' => $now, + 'updatevon' => $uid + ]); + if (isError($insertResult)) + return $insertResult; + + return success(['version' => $newVersion]); + } + + /** + * Writes the properties of one content version. + * @param array $daten content_id, sprache, version, template_kurzbz, oe_kurzbz, + * aktiv, menu_open, beschreibung, titel, sichtbar + * @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 + ]); + + $this->ci->ContentspracheModel->update($row->contentsprache_id, [ + 'titel' => $daten['titel'], + 'sichtbar' => $daten['sichtbar'], + 'updateamum' => $now, + 'updatevon' => $uid + ]); + + $this->ci->db->trans_complete(); + + if ($this->ci->db->trans_status() === false) + return error($this->ci->p->t('cms', 'speichernFehlgeschlagen')); + + return success(true); + } + + // legacy cms checks neither the permission + // type nor the lock. The form appears only when the user holds the lock, but the POST + // itself is unprotected. + /** + * @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($this->ci->p->t('cms', 'contentNichtGefunden')); + + $templateResult = $this->ci->TemplateModel->load($content[0]->template_kurzbz); + if (isError($templateResult)) + return $templateResult; + $template = getData($templateResult); + if (empty($template)) + return error($this->ci->p->t('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); + } + + /** + * Locks a version for the current user, or takes over an expired lock. + * @param int $contentsprache_id + * @return object success(true) or error + */ + public function lock($contentsprache_id) + { + $uid = getAuthUID(); + + $rowResult = $this->ci->ContentspracheModel->load($contentsprache_id); + if (isError($rowResult)) + return $rowResult; + $rowData = getData($rowResult); + if (empty($rowData)) + return error($this->ci->p->t('cms', 'versionNichtGefunden')); + + $holder = ($rowData[0]->gesperrt_uid === '') ? null : $rowData[0]->gesperrt_uid; + + if ($holder !== null && $holder !== $uid) + { + $openResult = $this->ci->ContentlogModel->getOpenLock($contentsprache_id, $holder); + $openEntry = (!isError($openResult)) ? getData($openResult) : null; + + if (!$this->isLockExpired($holder, $openEntry)) + return error($this->ci->p->t('cms', 'bereitsGesperrt')); + } + + $this->ci->db->trans_start(); + + $this->ci->ContentlogModel->closeOpenEntries($contentsprache_id); + + $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($this->ci->p->t('cms', 'speichernFehlgeschlagen')); + + return success(true); + } + + // legacy released ALL locks of the user. Releasing now ends one lock. + // The uid predicate stops a user releasing a lock of somebody else. + /** + * Releases the lock of the current user on one version. + * @param int $contentsprache_id + * @return object success(true) + */ + public function releaseOwnLock($contentsprache_id) + { + $uid = getAuthUID(); + + $this->ci->db->trans_start(); + + $this->ci->ContentlogModel->closeOpenEntries($contentsprache_id, $uid); + $this->ci->ContentspracheModel->releaseLock($contentsprache_id, $uid); + + $this->ci->db->trans_complete(); + + if ($this->ci->db->trans_status() === false) + return error($this->ci->p->t('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->ContentlogModel->closeOpenEntries($contentsprache_id); + $this->ci->ContentspracheModel->releaseLock($contentsprache_id); + + $this->ci->db->trans_complete(); + + if ($this->ci->db->trans_status() === false) + return error($this->ci->p->t('cms', 'speichernFehlgeschlagen')); + + return success(true); + } + + /** + * @param int $content_id + * @param string $sprache + * @param int $version + * @return object success(array) gesperrt_uid, start, expires, own, expired, may_force + */ + public function getLockState($content_id, $sprache, $version) + { + $versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version); + if (isError($versionResult)) + return $versionResult; + $row = getData($versionResult); + + // legacy admin.php inserts an empty string, releasing writes NULL. + // this code reports NULL. + $gesperrt_uid = ($row->gesperrt_uid === '') ? null : $row->gesperrt_uid; + + $logEntry = null; + if ($gesperrt_uid !== null) + { + $lockResult = $this->ci->ContentlogModel->getOpenLock( + $row->contentsprache_id, $gesperrt_uid + ); + $logEntry = (!isError($lockResult)) ? getData($lockResult) : null; + } + + return success([ + 'contentsprache_id' => (int) $row->contentsprache_id, + 'gesperrt_uid' => $gesperrt_uid, + 'start' => $logEntry ? $logEntry->start : null, + 'own' => ($gesperrt_uid !== null && $gesperrt_uid === getAuthUID()), + 'expired' => $this->isLockExpired($gesperrt_uid, $logEntry), + 'expires' => $this->lockExpiryDate($logEntry), + 'may_force' => $this->ci->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su') + ]); + } + + /** + * End of the lock window, null if the entry carries no start. + * @param stdClass|null $logEntry + * @return string|null + */ + private function lockExpiryDate($logEntry) + { + if ($logEntry === null || empty($logEntry->start)) + return null; + + $ttlHours = (int) $this->ci->config->item('lock_ttl_hours'); + + // lock() writes start with PHP time, so stay on PHP time. + return date('Y-m-d H:i:s', strtotime($logEntry->start) + $ttlHours * 3600); + } + + // Locks now age out after lock_ttl_hours. + /** + * A lock without a log entry has no age and counts as expired. + * @param string|null $gesperrt_uid + * @param stdClass|null $logEntry + * @return bool + */ + private function isLockExpired($gesperrt_uid, $logEntry) + { + if ($gesperrt_uid === null) + return false; + + $expires = $this->lockExpiryDate($logEntry); + + return $expires === null || strtotime($expires) < time(); + } + + /** + * @param int $content_id + * @return object success(true) or error + */ + public function deleteContent($content_id) + { + $this->ci->db->trans_start(); + + $this->ci->ContentchildModel->deleteByContent($content_id); + $this->ci->ContentlogModel->deleteByContent($content_id); + $this->ci->ContentspracheModel->deleteByContent($content_id); + $this->ci->ContentgruppeModel->deleteByContent($content_id); + $this->ci->ContentModel->delete($content_id); + + $this->ci->db->trans_complete(); + + if ($this->ci->db->trans_status() === false) + return error($this->ci->p->t('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($this->ci->p->t('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->ContentlogModel->deleteByContentsprache($row->contentsprache_id); + $this->ci->ContentspracheModel->deleteVersion($content_id, $sprache, $version); + + $this->ci->db->trans_complete(); + + if ($this->ci->db->trans_status() === false) + return error($this->ci->p->t('cms', 'loeschenFehlgeschlagen')); + + return success(true); + } +} diff --git a/application/libraries/CmsLib.php b/application/libraries/CmsLib.php index 355cf7c56..4fd85eec7 100644 --- a/application/libraries/CmsLib.php +++ b/application/libraries/CmsLib.php @@ -42,10 +42,12 @@ class CmsLib * @param int $version * @param string $sprache * @param boolean $sichtbar + * @param boolean $log false suppresses the click log entry. The CMS admin + * preview renders a content without counting a view. * * @return stdClass */ - public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true) + public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true, $log = true) { if (!is_numeric($content_id)) return error('ContentID ist ungueltig'); @@ -72,7 +74,7 @@ class CmsLib return $content; // Legt einen Logeintrag für die Klickstatistik an - if (defined('LOG_CONTENT') && LOG_CONTENT) { + if ($log && defined('LOG_CONTENT') && LOG_CONTENT) { // Nur eingeloggte User werden geloggt, das sonst auch alle Infoscreenaufrufe und dgl. mitgeloggt werden if (isLogged()) { $request_data = 'content_id=' . $content_id; diff --git a/application/libraries/XsdSchemaLib.php b/application/libraries/XsdSchemaLib.php new file mode 100644 index 000000000..d63baac4f --- /dev/null +++ b/application/libraries/XsdSchemaLib.php @@ -0,0 +1,195 @@ +ci =& get_instance(); + } + + /** + * 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($this->ci->p->t('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 .= "\n<" . $schema['root'] . '>'; + + foreach ($schema['fields'] as $field) + { + $value = isset($values[$field['name']]) ? $values[$field['name']] : ''; + $xml .= "\n<" . $field['name'] . '>_escapeCdata($value) . ']]>'; + } + + $xml .= "\n'; + + libxml_use_internal_errors(true); + $check = new DOMDocument(); + + if (!$check->loadXML($xml)) + { + libxml_clear_errors(); + return error($this->ci->p->t('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(']]>', ']]]]>', $value); + } +} diff --git a/application/models/content/Content_model.php b/application/models/content/Content_model.php index 48a155cb2..1578b529d 100644 --- a/application/models/content/Content_model.php +++ b/application/models/content/Content_model.php @@ -323,4 +323,266 @@ 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); + } + + /** + * Per content metadata for the tree filter. Excludes news, like getRootContent(). + * updateamum is the last touch on the content or on any of its versions. + * + * contentlength counts the visible characters of the newest version, the way getOne() + * picks it: the default language first, then the highest version. The XML scaffolding, + * the CDATA markers, the HTML tags, the entities and every space drop out, so a content + * that nobody ever filled measures 0 whatever empty shape it carries. The title counts + * too, so a content with a title and no body measures short instead of zero. + * + * @param string $uid current user, decides the mine flag + * @param string $sprache preferred language of the measured version + * @return stdClass success with array of rows or error + */ + public function getTreeMeta($uid, $sprache) + { + $query = " + SELECT tbl_content.content_id, + (SELECT COUNT(*) FROM campus.tbl_contentchild + WHERE tbl_contentchild.content_id = tbl_content.content_id) AS childcount, + tbl_content.insertamum, + GREATEST( + tbl_content.updateamum, + (SELECT MAX(updateamum) FROM campus.tbl_contentsprache + WHERE tbl_contentsprache.content_id = tbl_content.content_id) + ) AS updateamum, + COALESCE( + tbl_content.insertvon = ? OR tbl_content.updatevon = ? OR EXISTS ( + SELECT 1 FROM campus.tbl_contentsprache + WHERE tbl_contentsprache.content_id = tbl_content.content_id + AND (tbl_contentsprache.insertvon = ? + OR tbl_contentsprache.updatevon = ?) + ), + false + ) AS mine, + (SELECT length( + regexp_replace( + regexp_replace( + regexp_replace( + replace(replace(cs.content::text, '', ''), + '<[^>]*>', '', 'g'), + '&[a-zA-Z]+;|&#[0-9]+;', '', 'g'), + '\s+', '', 'g') + ) + FROM campus.tbl_contentsprache cs + WHERE cs.content_id = tbl_content.content_id + ORDER BY (cs.sprache = ?) DESC, cs.version DESC + LIMIT 1) AS contentlength + FROM campus.tbl_content + WHERE tbl_content.template_kurzbz <> ? + "; + + return $this->execReadOnlyQuery( + $query, [$uid, $uid, $uid, $uid, $sprache, 'news'] + ); + } + + /** + * 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); + } } diff --git a/application/models/content/Contentchild_model.php b/application/models/content/Contentchild_model.php index 11738d144..8253c7d54 100644 --- a/application/models/content/Contentchild_model.php +++ b/application/models/content/Contentchild_model.php @@ -1,6 +1,9 @@ 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]); + } + + + /** + * Parents of a content, with the title of each parent. + * A content can hang under more than one parent, so this returns a list. + * @param int $content_id + * @param string $sprache + * @return stdClass success with array of rows or error + */ + public function getParents($content_id, $sprache) + { + $query = ' + SELECT cc.contentchild_id, cc.content_id, cc.sort, + (SELECT titel FROM campus.tbl_contentsprache + WHERE content_id = cc.content_id AND sprache = ? + ORDER BY version LIMIT 1) AS titel + FROM campus.tbl_contentchild cc + WHERE cc.child_content_id = ? + ORDER BY cc.content_id + '; + + return $this->execReadOnlyQuery($query, [$sprache, $content_id]); + } + + /** + * Children of a content as a reader may see them. + * + * Differs from getChilds() on purpose. getChilds() serves the admin and returns every + * child with the lowest version. A reader needs the newest visible version, only active + * contents, and no page that a group rule keeps from them. + * + * The group rule follows CmsLib::getContent(): a content without a row in + * tbl_contentgruppe is open, a content with rows needs membership in one of them. + * + * @param int $content_id + * @param string $sprache + * @param string $uid + * @return stdClass success with array of rows or error + */ + public function getChildsForReader($content_id, $sprache, $uid) + { + $query = ' + SELECT + cc.child_content_id AS content_id, + cc.sort, + COALESCE( + (SELECT cs.titel FROM campus.tbl_contentsprache cs + WHERE cs.content_id = cc.child_content_id + AND cs.sprache = ? + AND cs.sichtbar + AND LENGTH(cs.titel) > 0 + ORDER BY cs.version DESC NULLS LAST LIMIT 1), + (SELECT cs.titel FROM campus.tbl_contentsprache cs + WHERE cs.content_id = cc.child_content_id + AND cs.sichtbar + AND LENGTH(cs.titel) > 0 + ORDER BY cs.version DESC NULLS LAST LIMIT 1), + c.beschreibung + ) AS titel + FROM campus.tbl_contentchild cc + JOIN campus.tbl_content c ON c.content_id = cc.child_content_id + WHERE cc.content_id = ? + AND c.aktiv + -- Without a visible version the reader cannot open the page at all. + AND EXISTS ( + SELECT 1 FROM campus.tbl_contentsprache cs + WHERE cs.content_id = cc.child_content_id AND cs.sichtbar + ) + AND ( + NOT EXISTS ( + SELECT 1 FROM campus.tbl_contentgruppe cg + WHERE cg.content_id = cc.child_content_id + ) + OR EXISTS ( + SELECT 1 FROM campus.tbl_contentgruppe cg + JOIN public.vw_gruppen vg ON vg.gruppe_kurzbz = cg.gruppe_kurzbz + WHERE cg.content_id = cc.child_content_id AND vg.uid = ? + ) + ) + ORDER BY cc.sort, cc.contentchild_id + '; + + return $this->execReadOnlyQuery($query, [$sprache, $content_id, $uid]); + } + + /** + * 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); + // A marker, not a message. The controller owns the wording, because a model has no + // phrases. Returning a phrase key here put the key itself in front of the user. + if (empty($neighbourData)) + return error(self::NO_NEIGHBOUR); + + $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); + } + + /** + * Writes the whole order of one content in one transaction. Serves the drag and drop. + * The list must hold every child of the content exactly once, or the rows left out + * would keep a stale sort value. + * @param int $content_id + * @param array $contentchild_ids in the wanted order + * @return stdClass success(true) or error + */ + public function setSortOrder($content_id, $contentchild_ids) + { + $currentResult = $this->execReadOnlyQuery( + 'SELECT contentchild_id FROM campus.tbl_contentchild WHERE content_id = ?', + [$content_id] + ); + if (isError($currentResult)) + return $currentResult; + + $current = []; + foreach ((array) getData($currentResult) as $row) + $current[] = (int) $row->contentchild_id; + + $wanted = array_map('intval', array_values($contentchild_ids)); + + $check = $wanted; + sort($current); + sort($check); + if ($current !== $check) + return error('Sort order does not match the children of the content'); + + $this->db->trans_start(); + + foreach ($wanted as $position => $contentchild_id) + { + $this->execQuery( + 'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?', + [$position + 1, $contentchild_id] + ); + } + + $this->db->trans_complete(); + + if ($this->db->trans_status() === false) + return error('Sort order update failed'); + + return success(true); + } + + /** + * Every ancestor of the given contents, with the distance to the content it belongs to. + * + * The tree shows a search result out of its place: a title like "Team" says nothing + * about which unit it belongs to. This delivers the way down to it. + * + * A content can hang under more than one parent, so a level can hold more than one + * ancestor. Every one of them comes back, with tiefe 1 for the direct parent. The + * caller decides how to draw that. + * + * tiefe also ends a cycle: a content whose parent chain leads back to itself would + * otherwise recurse forever. + * + * @param array $content_ids contents to walk up from + * @param int $maxTiefe how many levels to climb + * @param string $sprache language of the ancestor titles + * @return stdClass success with array of rows or error + */ + public function getAncestors($content_ids, $maxTiefe, $sprache) + { + if (empty($content_ids)) + return success([]); + + $platzhalter = implode(', ', array_fill(0, count($content_ids), '?')); + + // The aggregate and the title sit in the final select. The recursive term names + // the working table once and carries no subquery, which is what Postgres allows. + $query = ' + WITH RECURSIVE auf(start_id, content_id, tiefe) AS ( + SELECT cc.child_content_id, cc.content_id, 1 + FROM campus.tbl_contentchild cc + WHERE cc.child_content_id IN (' . $platzhalter . ') + UNION ALL + SELECT auf.start_id, cc.content_id, auf.tiefe + 1 + FROM campus.tbl_contentchild cc + JOIN auf ON cc.child_content_id = auf.content_id + WHERE auf.tiefe < ? + ) + SELECT auf.start_id, auf.content_id, MIN(auf.tiefe) AS tiefe, + (SELECT titel FROM campus.tbl_contentsprache cs + WHERE cs.content_id = auf.content_id AND cs.sprache = ? + ORDER BY cs.version LIMIT 1) AS titel, + -- The caller turns this into the entitled flag, so the path can offer a + -- link only for an ancestor the editor may open. + (SELECT c.oe_kurzbz FROM campus.tbl_content c + WHERE c.content_id = auf.content_id) AS oe_kurzbz + FROM auf + GROUP BY auf.start_id, auf.content_id + ORDER BY auf.start_id, MIN(auf.tiefe) DESC, auf.content_id + '; + + $params = array_map('intval', array_values($content_ids)); + $params[] = (int) $maxTiefe; + $params[] = $sprache; + + return $this->execReadOnlyQuery($query, $params); + } + + /** + * Removes a content from the child table, as parent and as child. + * @param int $content_id + * @return stdClass + */ + public function deleteByContent($content_id) + { + return $this->execQuery( + 'DELETE FROM campus.tbl_contentchild + WHERE content_id = ? OR child_content_id = ?', + [$content_id, $content_id] + ); + } } diff --git a/application/models/content/Contentgruppe_model.php b/application/models/content/Contentgruppe_model.php index 23dc897a1..f853a5387 100644 --- a/application/models/content/Contentgruppe_model.php +++ b/application/models/content/Contentgruppe_model.php @@ -10,6 +10,8 @@ class Contentgruppe_model extends DB_Model parent::__construct(); $this->dbTable = 'campus.tbl_contentgruppe'; $this->pk = array('gruppe_kurzbz', 'content_id'); + // Composite key without a sequence, so insert() must skip insert_id() and its LASTVAL(). + $this->hasSequence = false; } /** @@ -18,7 +20,7 @@ class Contentgruppe_model extends DB_Model * * @param int $content_id * - * @return stdClass success(true) wenn eingeschraenkt sonst success(false) + * @return stdClass success(true) wenn locked sonst success(false) */ public function islocked($content_id) { @@ -30,7 +32,7 @@ class Contentgruppe_model extends DB_Model } /** - * Prueft ob ein User die Berechtigung fuer das Anzeigen des + * Prüft ob ein User die Berechtigung fuer das Anzeigen des * Contents besitzt * * @param int $content_id ID des Contents @@ -56,5 +58,89 @@ 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); + } + + /** + * @param int $content_id + * @return stdClass + */ + public function deleteByContent($content_id) + { + return $this->execQuery( + 'DELETE FROM campus.tbl_contentgruppe WHERE content_id = ?', + [$content_id] + ); + } } diff --git a/application/models/content/Contentlog_model.php b/application/models/content/Contentlog_model.php index 025cb94aa..4a80794c1 100644 --- a/application/models/content/Contentlog_model.php +++ b/application/models/content/Contentlog_model.php @@ -11,4 +11,76 @@ class Contentlog_model extends DB_Model $this->dbTable = 'campus.tbl_contentlog'; $this->pk = 'contentlog_id'; } + + /** + * The open lock entry of a user (ende IS NULL). Returns null if none exists. + * Match the holder and take the newest, otherwise the age belongs to an abandoned entry. + * @param int $contentsprache_id + * @param string $uid holder of the lock + * @return stdClass success with row or null, or error + */ + public function getOpenLock($contentsprache_id, $uid) + { + $query = ' + SELECT contentlog_id, uid, contentsprache_id, start + FROM campus.tbl_contentlog + WHERE contentsprache_id = ? AND uid = ? AND ende IS NULL + ORDER BY start DESC + LIMIT 1 + '; + + $result = $this->execReadOnlyQuery($query, [$contentsprache_id, $uid]); + if (isError($result)) + return $result; + + $data = getData($result); + return success(empty($data) ? null : $data[0]); + } + + /** + * Ends every open lock entry of a version. Without a uid it ends the entry of every + * holder, which is what a take-over and a forced release need. + * @param int $contentsprache_id + * @param string|null $uid + * @return stdClass + */ + public function closeOpenEntries($contentsprache_id, $uid = null) + { + $query = 'UPDATE campus.tbl_contentlog SET ende = now() + WHERE contentsprache_id = ? AND ende IS NULL'; + $params = [$contentsprache_id]; + + if ($uid !== null) + { + $query .= ' AND uid = ?'; + $params[] = $uid; + } + + return $this->execQuery($query, $params); + } + + /** + * @param int $contentsprache_id + * @return stdClass + */ + public function deleteByContentsprache($contentsprache_id) + { + return $this->execQuery( + 'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id = ?', + [$contentsprache_id] + ); + } + + /** + * @param int $content_id + * @return stdClass + */ + public function deleteByContent($content_id) + { + return $this->execQuery( + 'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id IN + (SELECT contentsprache_id FROM campus.tbl_contentsprache WHERE content_id = ?)', + [$content_id] + ); + } } diff --git a/application/models/content/Contentsprache_model.php b/application/models/content/Contentsprache_model.php index 80b053e28..b1efa4465 100644 --- a/application/models/content/Contentsprache_model.php +++ b/application/models/content/Contentsprache_model.php @@ -38,5 +38,177 @@ 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]); + } + + /** + * Clears the lock of a version. Without a uid it clears whoever holds it, which is + * what a forced release needs. + * @param int $contentsprache_id + * @param string|null $uid + * @return stdClass + */ + public function releaseLock($contentsprache_id, $uid = null) + { + $query = 'UPDATE campus.tbl_contentsprache SET gesperrt_uid = NULL + WHERE contentsprache_id = ?'; + $params = [$contentsprache_id]; + + if ($uid !== null) + { + $query .= ' AND gesperrt_uid = ?'; + $params[] = $uid; + } + + return $this->execQuery($query, $params); + } + + /** + * @param int $content_id + * @return stdClass + */ + public function deleteByContent($content_id) + { + return $this->execQuery( + 'DELETE FROM campus.tbl_contentsprache WHERE content_id = ?', + [$content_id] + ); + } + + /** + * @param int $content_id + * @param string $sprache + * @param int $version + * @return stdClass + */ + public function deleteVersion($content_id, $sprache, $version) + { + return $this->execQuery( + 'DELETE FROM campus.tbl_contentsprache + WHERE content_id = ? AND sprache = ? AND version = ?', + [$content_id, $sprache, $version] + ); + } } diff --git a/application/models/content/Dms_model.php b/application/models/content/Dms_model.php index e9db6d903..a0c19bf9c 100644 --- a/application/models/content/Dms_model.php +++ b/application/models/content/Dms_model.php @@ -30,4 +30,241 @@ class Dms_model extends DB_Model return $returnArray; } + + /** + * Reads a DMS category and the access facts a listing needs. + * + * Mirrors the rule of cms/dms.php and dms.class.php at category level: + * a category is locked when any category in its parent chain grants a group or + * carries a berechtigung_kurzbz. The caller decides, this method only reports. + * + * @param string $kategorie_kurzbz + * gesperrt and in_gruppe come back as 0 or 1, not as a boolean. A PostgreSQL boolean + * reaches PHP as the string 'f' when the driver conversion does not run, and 'f' is + * truthy. The int cast is correct in both cases. + * + * @param string $uid + * @return object one row, or an empty result when the category does not exist + */ + public function getKategorieZugriff($kategorie_kurzbz, $uid) + { + // tiefe guards against a cyclic parent chain. + $query = ' + WITH RECURSIVE chain(kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, tiefe) AS ( + SELECT kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, 1 + FROM campus.tbl_dms_kategorie + WHERE kategorie_kurzbz = ? + UNION ALL + SELECT k.kategorie_kurzbz, k.parent_kategorie_kurzbz, k.berechtigung_kurzbz, c.tiefe + 1 + FROM campus.tbl_dms_kategorie k + JOIN chain c ON k.kategorie_kurzbz = c.parent_kategorie_kurzbz + WHERE c.tiefe < 32 + ) + SELECT + kat.kategorie_kurzbz, + kat.bezeichnung, + kat.beschreibung, + kat.berechtigung_kurzbz, + ( + EXISTS (SELECT 1 FROM chain WHERE chain.berechtigung_kurzbz IS NOT NULL) + OR EXISTS ( + SELECT 1 + FROM campus.tbl_dms_kategorie_gruppe g + JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz + ) + )::int AS gesperrt, + (EXISTS ( + SELECT 1 + FROM campus.tbl_dms_kategorie_gruppe g + JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz + JOIN public.tbl_benutzergruppe b ON b.gruppe_kurzbz = g.gruppe_kurzbz + WHERE b.uid = ? + ))::int AS in_gruppe + FROM campus.tbl_dms_kategorie kat + WHERE kat.kategorie_kurzbz = ? + '; + + return $this->execReadOnlyQuery($query, array($kategorie_kurzbz, $uid, $kategorie_kurzbz)); + } + + /** + * Lists the newest version of every document in one DMS category. + * + * filename stays out of the result. The reader gets the document through + * cms/dms.php?id=, never through the name on disk. + * + * @param string $kategorie_kurzbz + * @return object + */ + public function getKategorieDokumente($kategorie_kurzbz) + { + $query = ' + SELECT + d.dms_id, + v.version, + v.name, + v.beschreibung, + v.mimetype, + COALESCE(v.updateamum, v.insertamum) AS geaendert + FROM campus.tbl_dms d + JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id + WHERE d.kategorie_kurzbz = ? + -- cis_suche marks a document as published to CIS readers. A category also + -- holds working files, and those must not appear on a content page. + AND v.cis_suche + AND v.version = ( + SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id + ) + -- A project document carries its own rule in dms.class.php and does not + -- belong in a CMS category listing. + AND NOT EXISTS ( + SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id + ) + ORDER BY LOWER(v.name) + '; + + return $this->execReadOnlyQuery($query, array($kategorie_kurzbz)); + } + + /** + * Newest version of the named documents, in no particular order. + * + * Unlike getKategorieDokumente() this does NOT filter on cis_suche. An editor who names + * a dms_id has chosen that document on purpose, and the flag only marks a document for + * the CIS search. The category rule still applies, and the caller enforces it. + * + * @param array $ids dms_id values + * @return object + */ + public function getDokumenteByIds($ids) + { + if (empty($ids)) + return success(array()); + + $platzhalter = implode(', ', array_fill(0, count($ids), '?')); + + $query = ' + SELECT + d.dms_id, + d.kategorie_kurzbz, + v.version, + v.name, + v.beschreibung, + v.mimetype, + COALESCE(v.updateamum, v.insertamum) AS geaendert + FROM campus.tbl_dms d + JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id + WHERE d.dms_id IN (' . $platzhalter . ') + AND v.version = ( + SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id + ) + AND NOT EXISTS ( + SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id + ) + '; + + return $this->execReadOnlyQuery($query, array_values($ids)); + } + + /** + * Every document of one category, for the editor picker. + * + * Unlike getKategorieDokumente() this does NOT filter on cis_suche, because + * dms-dokumente renders named documents regardless of that flag. The flag travels with + * the row so the picker can mark a document that no reader would find by search. + * + * @param string $kategorie_kurzbz + * @return object + */ + public function getKategorieDokumenteAlle($kategorie_kurzbz) + { + $query = ' + SELECT + d.dms_id, + v.name, + v.cis_suche::int AS cis_suche + FROM campus.tbl_dms d + JOIN LATERAL ( + SELECT v.name, v.cis_suche + FROM campus.tbl_dms_version v + WHERE v.dms_id = d.dms_id + ORDER BY v.version DESC + LIMIT 1 + ) v ON TRUE + WHERE d.kategorie_kurzbz = ? + AND NOT EXISTS ( + SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id + ) + ORDER BY LOWER(v.name) + '; + + return $this->execReadOnlyQuery($query, array($kategorie_kurzbz)); + } + + /** + * Lists every DMS category with the number of documents it holds. + * Used by the insert dialog of the CMS content component "dms-liste". + * + * @return object + */ + public function getKategorien() + { + $query = ' + SELECT + k.kategorie_kurzbz, + k.bezeichnung, + k.oe_kurzbz, + COALESCE(a.anzahl, 0) AS anzahl + FROM campus.tbl_dms_kategorie k + LEFT JOIN ( + SELECT d.kategorie_kurzbz, COUNT(*) AS anzahl + FROM campus.tbl_dms d + JOIN ( + SELECT DISTINCT ON (dms_id) dms_id, cis_suche + FROM campus.tbl_dms_version + ORDER BY dms_id, version DESC + ) v ON v.dms_id = d.dms_id + WHERE v.cis_suche + GROUP BY d.kategorie_kurzbz + ) a ON a.kategorie_kurzbz = k.kategorie_kurzbz + ORDER BY LOWER(COALESCE(k.bezeichnung, k.kategorie_kurzbz)) + '; + + return $this->execReadOnlyQuery($query); + } + + /** + * The category itself plus its sub categories, down to a depth. + * + * tiefe 0 returns only the category named. The depth bound also ends a cycle: a + * category whose parent chain leads back to itself would otherwise recurse forever. + * + * The caller must still check the entitlement of every category it gets back. A sub + * category carries its own group rule and may be closed while the parent is open. + * + * @param string $kategorie_kurzbz root of the walk + * @param int $tiefe how many levels below the root to include + * @return object success with array of rows or error + */ + public function getMitUnterkategorien($kategorie_kurzbz, $tiefe) + { + $query = ' + WITH RECURSIVE unter(kategorie_kurzbz, tiefe) AS ( + SELECT kategorie_kurzbz, 0 + FROM campus.tbl_dms_kategorie + WHERE kategorie_kurzbz = ? + UNION ALL + SELECT k.kategorie_kurzbz, unter.tiefe + 1 + FROM campus.tbl_dms_kategorie k + JOIN unter ON k.parent_kategorie_kurzbz = unter.kategorie_kurzbz + WHERE unter.tiefe < ? + ) + SELECT kategorie_kurzbz, MIN(tiefe) AS tiefe + FROM unter + GROUP BY kategorie_kurzbz + ORDER BY MIN(tiefe), kategorie_kurzbz + '; + + return $this->execReadOnlyQuery($query, [$kategorie_kurzbz, (int) $tiefe]); + } } \ No newline at end of file diff --git a/application/models/content/Template_model.php b/application/models/content/Template_model.php index 6f452e23c..151f3ae09 100644 --- a/application/models/content/Template_model.php +++ b/application/models/content/Template_model.php @@ -11,4 +11,30 @@ class Template_model extends DB_Model $this->dbTable = 'campus.tbl_template'; $this->pk = 'template_kurzbz'; } + + /** + * True if the template carries a CIS4 stylesheet of its own, which is not true for every template. + * + * @param string $template_kurzbz + * @return stdClass success(bool) or error + */ + public function hasCis4Stylesheet($template_kurzbz) + { + $query = ' + SELECT (xslt_xhtml_c4 IS NOT NULL + AND xslt_xhtml_c4::text IS DISTINCT FROM xslt_xhtml::text) AS eigen + FROM campus.tbl_template + WHERE template_kurzbz = ? + '; + + $result = $this->execReadOnlyQuery($query, [$template_kurzbz]); + if (isError($result)) + return $result; + + $data = getData($result); + if (empty($data)) + return success(false); + + return success($data[0]->eigen === 't' || $data[0]->eigen === true); + } } diff --git a/application/models/organisation/Organisationseinheit_model.php b/application/models/organisation/Organisationseinheit_model.php index 9f64580a9..9291985ba 100644 --- a/application/models/organisation/Organisationseinheit_model.php +++ b/application/models/organisation/Organisationseinheit_model.php @@ -259,4 +259,37 @@ class Organisationseinheit_model extends DB_Model return $this->execReadOnlyQuery($qry, array($oe_kurzbz)); } + + /** + * The unit itself plus its sub units, down to a depth. + * + * tiefe 0 returns only the unit named, which is what a caller without a depth wants. + * The depth bound also ends a cycle: a unit whose parent chain leads back to itself + * would otherwise recurse forever. + * + * @param string $oe_kurzbz root of the walk + * @param int $tiefe how many levels below the root to include + * @return object success with array of rows or error + */ + public function getMitUntereinheiten($oe_kurzbz, $tiefe) + { + $query = ' + WITH RECURSIVE unter(oe_kurzbz, tiefe) AS ( + SELECT oe_kurzbz, 0 + FROM public.tbl_organisationseinheit + WHERE oe_kurzbz = ? + UNION ALL + SELECT o.oe_kurzbz, unter.tiefe + 1 + FROM public.tbl_organisationseinheit o + JOIN unter ON o.oe_parent_kurzbz = unter.oe_kurzbz + WHERE unter.tiefe < ? + ) + SELECT oe_kurzbz, MIN(tiefe) AS tiefe + FROM unter + GROUP BY oe_kurzbz + ORDER BY MIN(tiefe), oe_kurzbz + '; + + return $this->execReadOnlyQuery($query, [$oe_kurzbz, (int) $tiefe]); + } } diff --git a/application/models/person/Person_model.php b/application/models/person/Person_model.php index c893c51de..dd65e6c74 100644 --- a/application/models/person/Person_model.php +++ b/application/models/person/Person_model.php @@ -243,6 +243,58 @@ class Person_model extends DB_Model * @param $uid * @return array */ + /** + * Contact data of the named users, for the CMS content components. + * + * The joins follow Benutzerfunktion_model::getBenutzerFunktionenDetailed() and the view + * Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the same everywhere. + * + * foto_sperre is honoured. A person who blocked their photo never gets one on a public + * content page, no matter what the page asks for. + * + * @param array $uids + * @param boolean $mitFoto photos are large, so they are read only on request + * @return object + */ + public function getCmsKontakt($uids, $mitFoto = false) + { + if (empty($uids)) + return success(array()); + + $platzhalter = implode(', ', array_fill(0, count($uids), '?')); + $fotoSpalte = $mitFoto + ? 'CASE WHEN p.foto_sperre THEN NULL ELSE p.foto END' + : 'NULL'; + + $query = ' + SELECT + b.uid, + b.alias, + p.vorname, + p.nachname, + p.titelpre, + p.titelpost, + m.telefonklappe, + k.kontakt, + o.planbezeichnung, + ' . $fotoSpalte . ' AS foto + FROM public.tbl_benutzer b + JOIN public.tbl_person p ON p.person_id = b.person_id + LEFT JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid + LEFT JOIN public.tbl_kontakt k + ON k.standort_id = m.standort_id AND k.kontakttyp = ? + LEFT JOIN public.tbl_ort o ON o.ort_kurzbz = m.ort_kurzbz + WHERE b.uid IN (' . $platzhalter . ') + AND b.aktiv + '; + + $parameter = array('telefon'); + foreach ($uids as $uid) + $parameter[] = $uid; + + return $this->execReadOnlyQuery($query, $parameter); + } + public function getByUid($uid) { $this->addSelect('vorname, nachname, gebdatum, person_id, bpk, matr_nr, foto'); diff --git a/application/models/ressource/Mitarbeiter_model.php b/application/models/ressource/Mitarbeiter_model.php index d8bbd7d63..4318819f5 100644 --- a/application/models/ressource/Mitarbeiter_model.php +++ b/application/models/ressource/Mitarbeiter_model.php @@ -98,6 +98,31 @@ class Mitarbeiter_model extends DB_Model return $this->execQuery($qry, $params); } + /** + * Active staff, names only, for a picker. + * + * getPersonal() returns tbl_person.* including svnr, bpk and + * gebdatum, which is not needed in a CMS endpoint. + * + * @return object + */ + public function getAktivesPersonalNamen() + { + $query = ' + SELECT + b.uid, + p.vorname, + p.nachname + FROM public.tbl_benutzer b + JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid + JOIN public.tbl_person p ON p.person_id = b.person_id + WHERE b.aktiv + ORDER BY LOWER(p.nachname), LOWER(p.vorname) + '; + + return $this->execReadOnlyQuery($query); + } + /** * gibt Personen mit Übersicht von Vertragsdaten aus * diff --git a/application/models/system/Webservicelog_model.php b/application/models/system/Webservicelog_model.php index a5b23a396..052a45eb7 100644 --- a/application/models/system/Webservicelog_model.php +++ b/application/models/system/Webservicelog_model.php @@ -12,4 +12,72 @@ class Webservicelog_model extends DB_Model $this->dbTable = 'system.tbl_webservicelog'; $this->pk = 'webservicelog_id'; } + + /** + * The most viewed contents since a date, with the title of each one. + * request_id is a varchar, so the join casts the content_id to text. A cast of + * request_id to a number would fail on a row that holds no number. + * The join also drops a log row whose content is deleted. + * @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log + * @param string $sprache language of the title + * @param int $limit length of the ranking + * @return stdClass success with array of rows or error + */ + public function getContentClickCounts($since, $sprache, $limit) + { + $params = [$sprache, 'content']; + $sinceCondition = ''; + + if ($since !== null) + { + $sinceCondition = ' AND w.execute_time >= ?'; + $params[] = $since; + } + + $params[] = $limit; + + $query = ' + SELECT c.content_id, COUNT(*) AS hits, + (SELECT titel FROM campus.tbl_contentsprache + WHERE content_id = c.content_id AND sprache = ? + ORDER BY version LIMIT 1) AS titel + FROM system.tbl_webservicelog w + JOIN campus.tbl_content c ON c.content_id::text = w.request_id + WHERE w.webservicetyp_kurzbz = ?' . $sinceCondition . ' + GROUP BY c.content_id + ORDER BY COUNT(*) DESC, c.content_id + LIMIT ? + '; + + return $this->execReadOnlyQuery($query, $params); + } + + /** + * Views of one content since a date. A content outside the ranking still needs its + * own number. + * @param int $content_id + * @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log + * @return stdClass success with int or error + */ + public function getContentClickCount($content_id, $since) + { + $query = ' + SELECT COUNT(*) AS hits + FROM system.tbl_webservicelog + WHERE webservicetyp_kurzbz = ? AND request_id = ? + '; + $params = ['content', (string) $content_id]; + + if ($since !== null) + { + $query .= ' AND execute_time >= ?'; + $params[] = $since; + } + + $result = $this->execReadOnlyQuery($query, $params); + if (isError($result)) + return $result; + + return success((int) getData($result)[0]->hits); + } } diff --git a/application/views/CisRouterView/CisRouterView.php b/application/views/CisRouterView/CisRouterView.php index c51ca40c3..f02d17117 100644 --- a/application/views/CisRouterView/CisRouterView.php +++ b/application/views/CisRouterView/CisRouterView.php @@ -23,6 +23,7 @@ $includesArray = array( 'public/css/components/FormUnderline.css', 'public/css/components/abgabetool/abgabe.css', 'public/css/Cis4/Cms.css', + 'public/css/Cis4/Contentcomponents.css', 'public/css/Cis4/Studium.css' ), 'customJSs' => array( diff --git a/application/views/Cms.php b/application/views/Cms.php new file mode 100644 index 000000000..c14215316 --- /dev/null +++ b/application/views/Cms.php @@ -0,0 +1,35 @@ + '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/components/horizontalsplit.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); +?> +
+ + > +
+load->view('templates/FHC-Footer', $includesArray); ?> diff --git a/cms/tinymce_dms.php b/cms/tinymce_dms.php index 9971eb808..c21ed4604 100644 --- a/cms/tinymce_dms.php +++ b/cms/tinymce_dms.php @@ -83,12 +83,40 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null)) return confirm('Möchten Sie das File wirklich löschen?'); } + /* + * CIS4 bridge, ticket 78489. + * + * The TinyMCE 5 editor of the new CMS admin cannot receive a selection through the + * TinyMCE 3 API below, so this window posts the URL back to its opener instead. + * + * message: { typ: 'fhc-dms-selection', url: 'dms.php?id=' } + * target: window.opener, origin checked on both sides + * + * The path is chosen by window.name, which the opener sets and which survives the + * internal navigation of this browser. + * + * 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. Do not test for + // tinyMCEPopup, this page loads tiny_mce_popup.js itself and always defines it. + if (window.name === 'DMS_CIS4' && 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 diff --git a/public/css/Cms.css b/public/css/Cms.css new file mode 100644 index 000000000..a8674790d --- /dev/null +++ b/public/css/Cms.css @@ -0,0 +1,97 @@ +/* Full-height shell. Without a height chain down to .cms-admin the page itself scrolls + and the tabs leave the screen while paging through the tree. */ +html, +body { + height: 100%; +} + +body { + display: flex; + flex-direction: column; + overflow: hidden; +} + +#main { + flex: 1 1 auto; + min-height: 0; +} + +.cms-admin { + min-height: 0; +} + +/* The split component only caps its height with max-height, so give it a definite one. */ +.cms-admin .horizontalsplit-container { + height: 100%; +} + +.cms-admin .horizontalsplitted { + min-width: 0; + overscroll-behavior: contain; +} + +.cms-admin-main { + min-width: 0; + overflow: hidden; +} + +.cms-admin-tab-content { + min-height: 0; +} + +/* Anchor for the overlay that blocks the view during a content switch. */ +.cms-admin { + position: relative; +} + +.cms-admin-overlay { + position: absolute; + inset: 0; + z-index: 1050; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.6); + cursor: wait; +} + +/* content locker to groups get a gold padlock - resembles legacy cms */ +.cms-tree-lock { + color: #f0c000; + text-shadow: 0 0 1px #8a6000; +} + +/* A frame swallows the mouse events that reach it, so dragging the split handle over the + preview ends the drag. Both frames go through for the length of the drag, so the events + reach the page underneath. Only a drag sets this class, so the preview stays clickable + the rest of the time. */ +.cms-preview-dragging iframe { + pointer-events: none; +} + +/* The path down to a search hit, and the subtree under it. Both are context, so they step + back and let the hit itself carry the normal weight. */ +.cms-tree-ancestor { + font-size: 0.8em; + color: #6c757d; + line-height: 1.4; +} + +/* The path is context and keeps the grey, but it leads somewhere, so it underlines on + hover like any other link. */ +.cms-tree-ancestor a { + color: inherit; + text-decoration: none; +} + +.cms-tree-ancestor a:hover, +.cms-tree-ancestor a:focus { + color: var(--bs-link-color, #0d6efd); + text-decoration: underline; +} + +.cms-tree-context > div > a, +.cms-tree-context > div > span { + font-size: 0.85em; + color: #6c757d; +} diff --git a/public/js/api/factory/cms.js b/public/js/api/factory/cms.js index 59ad4af5a..1e361d0cd 100644 --- a/public/js/api/factory/cms.js +++ b/public/js/api/factory/cms.js @@ -16,7 +16,8 @@ */ export default { - content(content_id, version=null, sprache=null, sichtbar=null) { + // preview flag to suppress click log entry. The CMS admin preview passes it. + content(content_id, version=null, sprache=null, sichtbar=null, preview=false) { return { method: 'get', url: '/api/frontend/v1/Cms/content', @@ -24,7 +25,8 @@ export default { content_id, ...(version ? { version } : {}), ...(sprache ? { sprache } : {}), - ...(sichtbar ? { sichtbar } : {}) + ...(sichtbar ? { sichtbar } : {}), + ...(preview ? { preview: 1 } : {}) } }; }, @@ -54,6 +56,53 @@ export default { url: '/api/frontend/v1/Cms/getNewsRowCount' }; }, + // Used by the CMS content component "oe-personen". + // tiefe 0 asks about the one unit, higher takes that many levels of sub units with it. + getOePersonen(oe_kurzbz, foto, tiefe) { + return { + method: 'get', + url: '/api/frontend/v1/Cms/getOePersonen', + params: { + oe_kurzbz, + ...(foto ? { foto: 1 } : {}), + ...(tiefe ? { tiefe } : {}) + } + }; + }, + // Used by the CMS content component "person-block". + getPerson(uid, foto) { + return { + method: 'get', + url: '/api/frontend/v1/Cms/getPerson', + params: { uid, ...(foto ? { foto: 1 } : {}) } + }; + }, + // Used by the CMS content component "dms-dokumente". + getDmsDokumente(dms_ids) { + return { + method: 'get', + url: '/api/frontend/v1/Cms/getDmsDokumente', + params: { dms_ids } + }; + }, + // Used by the CMS content component "dms-liste". + // tiefe 0 asks about the one category, higher takes that many levels of sub categories + // with it. The server checks the entitlement of every category it reaches. + getDmsKategorie(kategorie_kurzbz, tiefe) { + return { + method: 'get', + url: '/api/frontend/v1/Cms/getDmsKategorie', + params: { kategorie_kurzbz, ...(tiefe ? { tiefe } : {}) } + }; + }, + // Used by the CMS content component "contentchild-menu". + getContentChilds(content_id, sprache) { + return { + method: 'get', + url: '/api/frontend/v1/Cms/getContentChilds', + params: { content_id, ...(sprache ? { sprache } : {}) } + }; + }, getNewsExtra() { // TODO(chris): seems to be called from nowhere? return { diff --git a/public/js/api/factory/cmsadmin.js b/public/js/api/factory/cmsadmin.js new file mode 100644 index 000000000..b4cb88401 --- /dev/null +++ b/public/js/api/factory/cmsadmin.js @@ -0,0 +1,296 @@ +export default { + + // --- CmsAdmin --- + + getTree(menu, filter) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getTree', + params: { menu, ...(filter ? { filter } : {}) } + }; + }, + getSubtree(content_id) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getSubtree', + params: { content_id } + }; + }, + 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 } + }; + }, + // Feeds the tree filter: child count, insertamum, updateamum and the own-edit flag. + getTreeMeta() { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getTreeMeta' + }; + }, + // Where each content sits. The tree asks for the whole visible list at once, so the + // hierarchy toggle costs one request and not one per node. + getAncestors(content_ids) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getAncestors', + params: { content_ids: content_ids.join(',') } + }; + }, + // Views of one content. One counting query, so the tab loads it right away. + // The server gates both click endpoints on the config flag and on the admin right, + // so a caller without both gets an error instead of numbers. + getClickCount(content_id, months) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getClickCount', + params: { content_id, months } + }; + }, + // The ranking over the whole log. Takes seconds, so the tab asks for it on request. + getClickRanking(months, sprache) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getClickRanking', + params: { months, sprache } + }; + }, + 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' + }; + }, + // Fills the DMS category field of the contentcomponent insert dialog. + getDmsKategorien() { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getDmsKategorien' + }; + }, + // Fills the person field of the contentcomponent insert dialog. + getMitarbeiter() { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getMitarbeiter' + }; + }, + // Fills the document field of the contentcomponent insert dialog. + getDmsKategorieDokumente(kategorie_kurzbz) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdmin/getDmsKategorieDokumente', + params: { kategorie_kurzbz } + }; + }, + 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 } + }; + }, + // The hierarchy tab lists them above the children. A content can have several parents. + getParents(content_id, sprache) { + return { + method: 'get', + url: '/api/frontend/v1/CmsAdminStruktur/getParents', + 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 } + }; + }, + // Writes the whole order after a drag and drop. The ids carry the wanted order. + putChildOrder(content_id, contentchild_ids) { + return { + method: 'post', + url: '/api/frontend/v1/CmsAdminStruktur/putChildOrder', + params: { content_id, contentchild_ids } + }; + } +}; diff --git a/public/js/apps/Cis/Cis.js b/public/js/apps/Cis/Cis.js index d4f846f25..5210a3b6d 100644 --- a/public/js/apps/Cis/Cis.js +++ b/public/js/apps/Cis/Cis.js @@ -137,7 +137,8 @@ const router = VueRouter.createRouter({ } }, { - path: `/CisVue/Cms/Content/:content_id`, + // optional version for cms admin preview + path: `/CisVue/Cms/Content/:content_id/:version?`, name: 'Content', component: CmsContent, props: true @@ -337,5 +338,9 @@ app.directive('contrast', contrast); app.mount('#fhccontent'); router.afterEach((to, from, failure) => { + // dont log cms admin preview + if (to.query.preview === '1') + return; + app.config.globalProperties.$api.call(ApiRouteInfo.info('cis4', to.fullPath)); }); diff --git a/public/js/apps/Cms.js b/public/js/apps/Cms.js new file mode 100644 index 000000000..324fc5e46 --- /dev/null +++ b/public/js/apps/Cms.js @@ -0,0 +1,44 @@ +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: [ + { + // Only content_id is required. CmsAdmin fills the missing parts + // from the content itself and rewrites the address. + 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'); diff --git a/public/js/components/Cis/Cms/Content.js b/public/js/components/Cis/Cms/Content.js index 355add08a..22ce5672a 100644 --- a/public/js/components/Cis/Cms/Content.js +++ b/public/js/components/Cis/Cms/Content.js @@ -38,7 +38,9 @@ export default { methods: { fetchContent(){ this.$api - .call(ApiCms.content(this.content_id_internal, this.version, this.sprache, this.sichtbar)) + .call(ApiCms.content( + this.content_id_internal, this.version, this.sprache, this.sichtbar, this.preview + )) .then(res => { this.$nextTick(function() { this.content = res.data.content; @@ -57,8 +59,14 @@ export default { } }, computed: { + // The viewer language, unless the address names one. The CMS admin previews a + // chosen language, which is not always the language of the editor interface. sprache(){ - return this.$p.user_language.value; + return this.$route?.query?.sprache || this.$p.user_language.value; + }, + // A preview renders the content without counting a view. + preview(){ + return this.$route?.query?.preview === '1'; }, computeContentType: function () { switch (this.content_type) { diff --git a/public/js/components/Cis/Cms/Content_types/General.js b/public/js/components/Cis/Cms/Content_types/General.js index cbc4224f9..88436d29b 100644 --- a/public/js/components/Cis/Cms/Content_types/General.js +++ b/public/js/components/Cis/Cms/Content_types/General.js @@ -1,4 +1,5 @@ import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js" +import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js"; export default { name: "GeneralComponent", props:{ @@ -6,7 +7,13 @@ export default { type:String, required:true, }, + content_id:{ + type: [Number, String], + }, }, + components: { + ContentcomponentHost + }, methods: { sanitizeLegacyTables(table) { @@ -124,7 +131,7 @@ export default { template: /*html*/ `
-
+

Content was not found

`, diff --git a/public/js/components/Cis/Cms/Content_types/News_content.js b/public/js/components/Cis/Cms/Content_types/News_content.js index a17e8592a..deaf90288 100644 --- a/public/js/components/Cis/Cms/Content_types/News_content.js +++ b/public/js/components/Cis/Cms/Content_types/News_content.js @@ -134,6 +134,7 @@ export default { template: /*html*/ `
+

Content was not found

diff --git a/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js b/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js index 902a05aca..c98a0d9b5 100644 --- a/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js +++ b/public/js/components/Cis/Cms/Content_types/Raum_contentmittitel.js @@ -1,4 +1,4 @@ - +import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js"; export default { name: "RaumComponent", data() { @@ -15,6 +15,9 @@ export default { type: [Number, String], } }, + components: { + ContentcomponentHost + }, methods: { sanitizeLegacyTables(table) { @@ -135,7 +138,7 @@ export default {
-
+

Content was not found

`, }; diff --git a/public/js/components/Cis/Cms/News.js b/public/js/components/Cis/Cms/News.js index 7c73bcb67..d272a36b6 100644 --- a/public/js/components/Cis/Cms/News.js +++ b/public/js/components/Cis/Cms/News.js @@ -117,6 +117,7 @@ export default { >
+
diff --git a/public/js/components/Cms/CmsAdmin.js b/public/js/components/Cms/CmsAdmin.js new file mode 100644 index 000000000..cb374dba5 --- /dev/null +++ b/public/js/components/Cms/CmsAdmin.js @@ -0,0 +1,303 @@ +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 Hierarchy from './Tabs/Hierarchy.js'; +import History from './Tabs/History.js'; +import Delete from './Tabs/Delete.js'; +import Clickstats from './Tabs/Clickstats.js'; +import HorizontalSplit from '../horizontalsplit/horizontalsplit.js'; + +const TAB_COMPONENTS = { + properties: Properties, + content: ContentTab, + permissions: Permissions, + hierarchy: Hierarchy, + history: History, + delete: Delete +}; + +const TABS = [ + { key: 'properties', phrase: 'cms/tabEigenschaften' }, + { key: 'content', phrase: 'cms/tabInhalt' }, + { key: 'permissions', phrase: 'cms/tabRechte' }, + { key: 'hierarchy', phrase: 'cms/tabHierarchie' }, + { key: 'history', phrase: 'cms/tabHistory' }, + { key: 'delete', phrase: 'cms/tabLoeschen' } +]; + +// The view renders the clickstats attribute only for an admin, and only with the config +// flag set. +const CLICKSTATS_TAB = { key: 'clickstats', phrase: 'cms/tabKlickstatistik' }; + +export default { + name: 'CmsAdmin', + components: { + 'cms-tree': ContentTree, + 'cms-content-header': ContentHeader, + 'horizontal-split': HorizontalSplit, + ...TAB_COMPONENTS + }, + props: { + cmsRoot: String, + authUid: String, + defaultLanguage: { type: String, default: 'German' }, + clickstats: { type: Boolean, default: false } + }, + data() { + return { + contentId: null, + sprache: null, + version: null, + tab: 'properties', + contentInfo: null, + pending: 0 + }; + }, + computed: { + tabs() { + return this.clickstats ? TABS.concat([CLICKSTATS_TAB]) : TABS; + }, + tabComponents() { + return this.clickstats + ? { ...TAB_COMPONENTS, clickstats: Clickstats } + : TAB_COMPONENTS; + }, + activeTabComponent() { + return this.tabComponents[this.tab] || null; + }, + busy() { + return this.pending > 0; + } + }, + 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; + // unknown tab fallback + const tab = this.tabComponents[params.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) return; + + if (this.contentInfo?.content_id !== id) { + this.loadContentInfo().then(() => { this.applyRouteDefaults(); }); + } else { + this.applyRouteDefaults(); + } + } + }, + immediate: true + } + }, + methods: { + loadContentInfo() { + if (this.contentId === null) { + this.contentInfo = null; + return Promise.resolve(); + } + this.pending++; + return this.$api + .call(ApiCmsAdmin.getContent(this.contentId)) + .then(result => { + this.contentInfo = result.data; + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.pending--; }); + }, + + // Language, version and tab are optional in the address. Fill in what the address + // leaves out, and replace a value that the content does not have. Then write the + // complete address back, so a reload and a copied link both work. + applyRouteDefaults() { + if (this.contentId === null || !this.contentInfo) return; + + const languages = this.contentInfo.languages || []; + const versions = this.contentInfo.versions || {}; + + let lang = this.sprache; + if (lang === null || !languages.includes(lang)) { + lang = languages.includes(this.defaultLanguage) + ? this.defaultLanguage + : languages[0] || null; + } + + const langVersions = (lang && versions[lang]) ? versions[lang] : []; + let ver = this.version; + if (ver === null || !langVersions.includes(ver)) { + ver = langVersions.length ? Math.max(...langVersions) : null; + } + + if (lang === this.sprache && ver === this.version) return; + + this.sprache = lang; + this.version = ver; + // Replace, or the short address stays in the history and the back button + // walks through both forms of the same page. + this.pushRoute(true); + }, + + 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; + } + + // Set id, language and version in one flush, or the tabs query the new content + // with the version of the old one. + this.pending++; + this.$api + .call(ApiCmsAdmin.getContent(content_id)) + .then(result => { + this.contentId = content_id; + this.contentInfo = result.data; + this.preselectLanguageVersion(); + this.pushRoute(); + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.pending--; }); + }, + + selectTab(tab) { + this.tab = tab; + this.pushRoute(); + }, + + selectLanguageVersion(sprache, version) { + this.sprache = sprache; + this.version = version; + this.pushRoute(); + }, + + // Three grades of tree update. Pick the cheapest one that stays correct. + // A full reload drops the expanded branches and refetches the filter meta, + // so keep it for changes that move nodes between branches. + reloadTree() { + if (this.$refs.tree) this.$refs.tree.reload(); + }, + + refreshTreeNode(contentId) { + if (this.$refs.tree) this.$refs.tree.refreshNode(contentId); + }, + + patchTreeNode(payload) { + if (this.$refs.tree) this.$refs.tree.patchNode(payload.content_id, payload.patch); + }, + + 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(replace = false) { + if (this.contentId === null) { + this.$router.push({ name: 'index' }); + return; + } + + // The optional parts are positional. Stop at the first missing one, or the + // address carries an empty segment. + const params = { content_id: this.contentId }; + if (this.sprache !== null) { + params.sprache = this.sprache; + if (this.version !== null) { + params.version = this.version; + params.tab = this.tab; + } + } + + const target = { name: 'content', params }; + if (replace) { + this.$router.replace(target); + } else { + this.$router.push(target); + } + } + }, + template: ` +
+ + + + + +
+
+ {{ $p.t('ui/loading') }} +
+
+
+ ` +}; diff --git a/public/js/components/Cms/Form/ContentcomponentDialog.js b/public/js/components/Cms/Form/ContentcomponentDialog.js new file mode 100644 index 000000000..ff5fe7ded --- /dev/null +++ b/public/js/components/Cms/Form/ContentcomponentDialog.js @@ -0,0 +1,381 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; +import { + catalog, + getDescriptor, + CONTENTCOMPONENT_CLASS, + CONTENTCOMPONENT_ATTR +} from '../../Contentcomponents/catalog.js'; + +/** + * The insert and edit dialogs for a contentcomponent marker. + * + * Lives apart from FieldWysiwyg so that file keeps to the editor itself. Everything that + * knows about the catalog, the marker markup and the dialogs is here. + */ + +// Helper fields a picker adds to a dialog. They must not collide with a prop name, and +// markerHtml ignores them because it walks the catalog, not the dialog data. +const FELD_KATEGORIE = '__kategorie'; +const FELD_DOKUMENT = '__dokument'; + +// Filled in createContentcomponentDialog. The factory is no Vue component, so the +// phrases plugin is handed in instead of read from an instance. +let t = key => key; + +// A function, not a constant: the text is translated when the dialog is built. +const leereAuswahl = () => ({ value: '', text: t('ccAuswaehlen') }); + +// A prop source fills a dialog field with a list from the server. The key is the +// "source" value in the catalog. A prop without a known source gets a plain text field. +const SOURCES = { + oe: { + request: () => ApiCmsAdmin.getOrganisationseinheiten(), + item: oe => ({ + value: oe.oe_kurzbz, + text: (oe.bezeichnung || oe.oe_kurzbz) + + (oe.aktiv ? '' : ' (' + t('inaktiv') + ')') + }) + }, + dmskategorie: { + request: () => ApiCmsAdmin.getDmsKategorien(), + item: kat => ({ + value: kat.kategorie_kurzbz, + text: (kat.bezeichnung || kat.kategorie_kurzbz) + ' (' + kat.anzahl + ')' + }) + }, + // Surname first + person: { + request: () => ApiCmsAdmin.getMitarbeiter(), + item: person => ({ + value: person.uid, + text: person.nachname + ' ' + person.vorname + ' (' + person.uid + ')' + }) + } +}; + +function escapeAttr(value) +{ + return String(value) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +// Builds the marker. It stays empty: CIS4 renders the component into it, and every other +// renderer shows nothing. NOTE: if a marker disappears on save, check the TinyMCE +// serializer first. noneditable keeps the node, but an empty block is the exposed part. +function markerHtml(name, descriptor, data) +{ + let attrs = ''; + + for (const propName of Object.keys(descriptor.props || {})) + { + const value = data[propName]; + // false is an unchecked box. The absent attribute already means false. + if (value === '' || value === false || value === undefined || value === null) + continue; + attrs += ' data-' + propName + '="' + escapeAttr(value) + '"'; + } + + return '
'; +} + +// Reads an existing marker back into dialog values. +function readMarker(node) +{ + const name = node.getAttribute(CONTENTCOMPONENT_ATTR); + const descriptor = getDescriptor(name); + + if (!descriptor) + return null; + + const values = {}; + for (const propName of Object.keys(descriptor.props || {})) + values[propName] = node.getAttribute('data-' + propName) || ''; + + return { name: name, values: values }; +} + +/** + * @param {object} options { api: the $api plugin, onChanged: called after a write } + */ +export function createContentcomponentDialog(options) +{ + const api = options.api; + const onChanged = options.onChanged || function () {}; + + t = (key, args) => options.p.t('cms/' + key, args); + + const sourceItems = {}; + const sourceRequests = {}; + + // These lists are large and rarely needed. Load each one once, on demand. + function loadSource(key) + { + if (sourceRequests[key]) + return sourceRequests[key]; + + const source = SOURCES[key]; + + sourceRequests[key] = api + .call(source.request()) + .then(res => { sourceItems[key] = (res.data || []).map(source.item); }) + .catch(() => { sourceItems[key] = []; }); + + return sourceRequests[key]; + } + + // Step one: pick the component. A second dialog then asks for its properties. + // Two dialogs avoid the redial logic a single changing dialog would need. + function openPicker(editor) + { + if (!catalog.length) + return; + + editor.windowManager.open({ + title: t('ccDialogTitel'), + initialData: { component: catalog[0].name }, + body: { + type: 'panel', + items: [{ + type: 'selectbox', + name: 'component', + label: t('ccContentcomponent'), + items: catalog.map(entry => ({ + value: entry.name, + text: t(entry.labelPhrase) + })) + }] + }, + buttons: [ + { type: 'cancel', text: t('abbrechen') }, + { type: 'submit', text: t('weiter'), primary: true } + ], + onSubmit(dialog) { + const name = dialog.getData().component; + dialog.close(); + openProperties(editor, name, null, null); + } + }); + } + + // Step two. node is null for an insert and the marker element for an edit. + function openProperties(editor, name, values, node) + { + const descriptor = getDescriptor(name); + + if (!descriptor) + return; + + const specs = Object.entries(descriptor.props || {}); + + const sources = specs + .map(([, spec]) => spec.source) + .filter((key, i, all) => SOURCES[key] && all.indexOf(key) === i); + + // A document picker needs the category list even though no prop declares it. + if (specs.some(([, spec]) => spec.picker === 'dms') && sources.indexOf('dmskategorie') === -1) + sources.push('dmskategorie'); + + Promise.all(sources.map(loadSource)).then(() => { + // Documents of the category currently chosen in the dialog. A category change + // refills this and redials, because the items of a selectbox are fixed once + // the dialog is built. + let dokumente = []; + + // A stored value that is no longer in the list, for example a person who left. + // Without this extra option the select would fall back to its first entry and + // silently replace the value on save. slice() keeps the cache clean. + function listeFuer(propName, spec) + { + const liste = (sourceItems[spec.source] || []).slice(); + const wert = values && values[propName]; + + if (wert && !liste.some(eintrag => eintrag.value === wert)) + liste.unshift({ value: wert, text: t('ccNichtInDerAuswahl', [wert]) }); + + return liste; + } + + function itemsBauen() + { + return specs.reduce((alle, [propName, spec]) => { + if (SOURCES[spec.source]) + { + alle.push({ + type: 'selectbox', + name: propName, + label: spec.labelPhrase ? t(spec.labelPhrase) : propName, + items: listeFuer(propName, spec) + }); + return alle; + } + + if (spec.type === 'boolean') + { + alle.push({ + type: 'checkbox', + name: propName, + label: spec.labelPhrase ? t(spec.labelPhrase) : propName + }); + return alle; + } + + alle.push({ + type: 'input', + name: propName, + label: spec.labelPhrase ? t(spec.labelPhrase) : propName + }); + + if (spec.picker === 'dms') + { + alle.push({ + type: 'selectbox', + name: propName + FELD_KATEGORIE, + label: t('ccKategorie'), + items: [leereAuswahl()].concat(sourceItems.dmskategorie || []) + }); + alle.push({ + type: 'selectbox', + name: propName + FELD_DOKUMENT, + label: t('ccDokumentHinzufuegen'), + items: [leereAuswahl()].concat(dokumente) + }); + } + + return alle; + }, []); + } + + function konfigBauen(daten) + { + return { + title: t(descriptor.labelPhrase), + initialData: daten, + body: { type: 'panel', items: itemsBauen() }, + onChange(dialog, details) { + aufAenderung(dialog, details.name); + }, + buttons: [ + { type: 'cancel', text: t('abbrechen') }, + { type: 'submit', text: t('uebernehmen'), primary: true } + ], + onSubmit(dialog) { + const html = markerHtml(name, descriptor, dialog.getData()); + dialog.close(); + + if (node) + editor.dom.setOuterHTML(node, html); + else + editor.insertContent(html); + + onChanged(); + } + }; + } + + function aufAenderung(dialog, feld) + { + // A new category means a new document list, and only redial can replace + // the options of a selectbox. + if (feld.endsWith(FELD_KATEGORIE)) + { + const ziel = feld.slice(0, -FELD_KATEGORIE.length); + const daten = dialog.getData(); + const kategorie = daten[feld]; + + daten[ziel + FELD_DOKUMENT] = ''; + + if (!kategorie) + { + dokumente = []; + dialog.redial(konfigBauen(daten)); + return; + } + + api + .call(ApiCmsAdmin.getDmsKategorieDokumente(kategorie)) + .then(res => { + dokumente = (res.data || []).map(dokument => ({ + value: String(dokument.dms_id), + // A document outside the CIS search still renders here, so + // the editor has to see which one that is. + text: dokument.cis_suche + ? dokument.name + : t('ccNichtInCisSuche', [dokument.name]) + })); + }) + .catch(() => { dokumente = []; }) + .then(() => { dialog.redial(konfigBauen(daten)); }); + + return; + } + + // Choosing a document appends it and clears the select, so the next one can + // be picked straight away. The reset fires this handler again with an empty + // value, which the guard below drops. + if (feld.endsWith(FELD_DOKUMENT)) + { + const ziel = feld.slice(0, -FELD_DOKUMENT.length); + const daten = dialog.getData(); + const wert = daten[feld]; + + if (!wert) + return; + + const ids = String(daten[ziel] || '') + .split(',') + .map(teil => teil.trim()) + .filter(teil => teil); + + if (ids.indexOf(wert) === -1) + ids.push(wert); + + const patch = {}; + patch[ziel] = ids.join(', '); + patch[feld] = ''; + dialog.setData(patch); + } + } + + // Every field needs a start value, otherwise the dialog reports it as unknown. + const startdaten = {}; + for (const [propName, spec] of specs) + { + if (spec.type === 'boolean') + { + startdaten[propName] = !!values && values[propName] === 'true'; + continue; + } + + const liste = sourceItems[spec.source]; + startdaten[propName] = (values && values[propName]) + || (liste && liste.length ? liste[0].value : ''); + + if (spec.picker === 'dms') + { + startdaten[propName + FELD_KATEGORIE] = ''; + startdaten[propName + FELD_DOKUMENT] = ''; + } + } + + editor.windowManager.open(konfigBauen(startdaten)); + }); + } + + // Opens the property dialog of an existing marker. + function openMarker(editor, node) + { + const marker = readMarker(node); + + if (marker) + openProperties(editor, marker.name, marker.values, node); + } + + return { + openPicker: openPicker, + openMarker: openMarker + }; +} diff --git a/public/js/components/Cms/Form/FieldString.js b/public/js/components/Cms/Form/FieldString.js new file mode 100644 index 000000000..edaa9c456 --- /dev/null +++ b/public/js/components/Cms/Form/FieldString.js @@ -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: ` +
+ + +
+ ` +}; diff --git a/public/js/components/Cms/Form/FieldWysiwyg.js b/public/js/components/Cms/Form/FieldWysiwyg.js new file mode 100644 index 000000000..50b38458b --- /dev/null +++ b/public/js/components/Cms/Form/FieldWysiwyg.js @@ -0,0 +1,162 @@ +import { CONTENTCOMPONENT_CLASS, CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js'; +import { createContentcomponentDialog } from './ContentcomponentDialog.js'; + +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; + }, + + // Opens the legacy DMS browser and resolves with the relative URL it posts back. + // Used for images and links, where the editor needs the file preview of that + // browser. The contentcomponent dialog picks documents from a list instead, without + // leaving the dialog. The bridge in cms/tinymce_dms.php sends { typ, url } to + // window.opener and closes itself. + openDmsBrowser() { + const vm = this; + + return new Promise(resolve => { + if (vm._dmsListener) + window.removeEventListener('message', vm._dmsListener); + + const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php'; + // The window name selects the bridge path in cms/tinymce_dms.php and survives + // its internal navigation. The legacy admin opens the same page as 'DMS'. + window.open(url, 'DMS_CIS4', '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; + + window.removeEventListener('message', onMessage); + vm._dmsListener = null; + resolve(event.data.url); + }; + + vm._dmsListener = onMessage; + window.addEventListener('message', onMessage); + }); + } + }, + watch: { + modelValue(newVal) { + if (this.editor && this.editor.getContent() !== newVal) { + this.editor.setContent(newVal || ''); + } + } + }, + mounted() { + const vm = this; + + // The dialogs live in their own module. This file stays with the editor itself. + this.contentcomponentDialog = createContentcomponentDialog({ + api: this.$api, + // The dialog module is no component, so it cannot reach the phrases itself. + p: this.$p, + onChanged: () => vm.$emit('update:modelValue', vm.editor.getContent()) + }); + + 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: '*[*]', + // A contentcomponent marker is one atomic block. The editor must not type into it. + noneditable_noneditable_class: CONTENTCOMPONENT_CLASS, + // The marker renders nothing here, so the editor has to show that it is there. + content_style: '.' + CONTENTCOMPONENT_CLASS + '{' + + 'display:block;margin:.5rem 0;padding:.5rem;min-height:2.5rem;' + + 'border:1px dashed #7a7a7a;background:#f2f2f2;cursor:pointer;}' + + '.' + CONTENTCOMPONENT_CLASS + '::before{' + + 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');' + + 'font:12px monospace;color:#555;}', + forced_root_block: '', + // No style_formats, and no styleselect in the toolbar below. The six formats + // of the legacy admin are gone, and none of them earned a port. + // + // Three wrote a div, while every stylesheet targets a td: skin/styles/tw.css + // declares td.cmscontent, td.menubox and td.teambox. The live content agrees, + // it carries those classes on a td about 1400 times and on a div twice. + // Tablesorter wrote tablesorter_col_0, which no file in this repository + // reads, and skin/style.css.php never serves skin/tablesort.css. + // + // cmstable and screenshot_boxshadow did work, but only in the legacy + // renderer. public/css/Cis4/Cms.css binds its copies to #cms, and CIS4 + // renders into #fhc-cms-content. CIS4 is the target, so they went too. + // + // A styling option for editors is a feature of its own. See + // docs/cms-darstellung-entwurf.md. + plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr noneditable', + toolbar: [ + 'code | contentcomponent | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | 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' + ], + // 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. + file_picker_callback: (callback, value, meta) => { + vm.openDmsBrowser().then(callback); + }, + setup: (editor) => { + vm.editor = editor; + + editor.ui.registry.addButton('contentcomponent', { + text: vm.$p.t('cms/ccContentcomponent'), + tooltip: vm.$p.t('cms/ccDialogTitel'), + onAction: () => vm.contentcomponentDialog.openPicker(editor) + }); + + // A double click on a marker reopens its property dialog. + editor.on('dblclick', (event) => { + const node = editor.dom.getParent(event.target, '.' + CONTENTCOMPONENT_CLASS); + + if (node) + vm.contentcomponentDialog.openMarker(editor, node); + }); + + 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: ` +
+ + +
+ ` +}; diff --git a/public/js/components/Cms/Form/XsdForm.js b/public/js/components/Cms/Form/XsdForm.js new file mode 100644 index 000000000..996800936 --- /dev/null +++ b/public/js/components/Cms/Form/XsdForm.js @@ -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: ` +
+ +
+ ` +}; diff --git a/public/js/components/Cms/Header/ContentHeader.js b/public/js/components/Cms/Header/ContentHeader.js new file mode 100644 index 000000000..b6132da7d --- /dev/null +++ b/public/js/components/Cms/Header/ContentHeader.js @@ -0,0 +1,180 @@ +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'], + data() { + return { + titel: '', + allSprachen: [], + versionDetails: [], + // Target of the translation dropdown, used once the list grows past a pair. + neueSprache: '' + }; + }, + 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.alertSuccess(this.$p.t('cms/neueVersionAngelegt')); + 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(newSprache) { + if (!newSprache) return; + this.neueSprache = ''; + this.$api + .call(ApiCmsAdmin.postTranslation( + this.contentId, this.sprache, this.version, newSprache + )) + .then(() => { + this.$fhcAlert.alertSuccess(this.$p.t('cms/uebersetzungAngelegt')); + this.$emit('reload-content-info'); + this.$emit('select-language-version', newSprache, this.version); + }) + .catch(this.$fhcAlert.handleSystemError); + } + }, + mounted() { + this.loadSprachen(); + this.loadTitel(); + this.loadVersionDetails(); + }, + template: ` +
+
+ {{ $p.t('cms/contentId') }}: {{ contentId }} + | {{ $p.t('cms/version') }}: {{ version }} + | {{ $p.t('cms/sprache') }}: {{ sprache }} + | {{ $p.t('cms/titel') }}: {{ titel }} +
+
+
+ + +
+
+ +
+
+ + + +
+
+
+ ` +}; diff --git a/public/js/components/Cms/Header/LanguageSelect.js b/public/js/components/Cms/Header/LanguageSelect.js new file mode 100644 index 000000000..97935da13 --- /dev/null +++ b/public/js/components/Cms/Header/LanguageSelect.js @@ -0,0 +1,36 @@ +export default { + name: 'SpracheSelect', + props: { + languages: { type: Array, default: () => [] }, + sprache: String + }, + emits: ['select-language'], + computed: { + // Two languages read faster as a pair of buttons, and that is the case here. Other + // institutions run this software with more content languages, and there the row of + // buttons pushes the rest of the header off the line. + alsAuswahl() { + return this.languages.length > 2; + } + }, + template: ` + +
+ +
+ ` +}; diff --git a/public/js/components/Cms/Header/VersionSelect.js b/public/js/components/Cms/Header/VersionSelect.js new file mode 100644 index 000000000..bd4396acd --- /dev/null +++ b/public/js/components/Cms/Header/VersionSelect.js @@ -0,0 +1,81 @@ +// Versions shown on each side of the open one. +const WINDOW = 3; + +export default { + name: 'VersionSelect', + props: { + versions: { type: Array, default: () => [] }, + versionDetails: { type: Array, default: () => [] }, + version: Number + }, + emits: ['select-version'], + computed: { + // A content can carry dozens of versions, and one button each breaks the header. + // Show the open version with three neighbours on each side. The first and the last + // stay reachable in one click, and a gap marks what the window leaves out. + // Below the threshold the window would hide nothing, so show every version. + items() { + const all = this.versions; + const active = all.indexOf(this.version); + + if (active === -1 || all.length <= WINDOW * 2 + 3) + return all.map(value => ({ type: 'version', value })); + + const from = Math.max(0, active - WINDOW); + const to = Math.min(all.length - 1, active + WINDOW); + const items = []; + + if (from > 0) { + items.push({ type: 'version', value: all[0] }); + // One version between the first and the window needs no gap marker. + if (from > 1) items.push({ type: 'gap', count: from - 1 }); + } + + for (let i = from; i <= to; i++) + items.push({ type: 'version', value: all[i] }); + + if (to < all.length - 1) { + if (to < all.length - 2) + items.push({ type: 'gap', count: all.length - 2 - to }); + items.push({ type: 'version', value: all[all.length - 1] }); + } + + return items; + }, + 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: ` +
+ +
+ ` +}; diff --git a/public/js/components/Cms/Preview.js b/public/js/components/Cms/Preview.js new file mode 100644 index 000000000..4c0e13299 --- /dev/null +++ b/public/js/components/Cms/Preview.js @@ -0,0 +1,308 @@ +// Safety net for the spinner. +const CIS4_LOAD_TIMEOUT = 10000; + +// Cis.js reads window.innerWidth and switches to its mobile layout below 767px. In a frame +// that is the width of the frame, so these widths drive the real layout and not a zoom. +// Its resize listener reacts on its own, which means no reload and no second long wait. +const CIS4_WIDTHS = { + mobile: 375, + desktop: null +}; + +export default { + name: 'CmsPreview', + props: { + contentId: Number, + sprache: String, + version: Number, + templateKurzbz: String, + // The legacy renderer runs no Vue, so it draws nothing for a contentcomponent + // marker. The CIS4 view mounts them, which is the whole point of switching. + hasContentcomponents: Boolean, + // False for a template whose xslt_xhtml_c4 is a copy of the legacy stylesheet. + // Such a template emits a whole HTML document, and the CIS4 renderer writes that + // into a div, so the layout is lost. Defaults true, so an older payload warns + // about nothing. + hasCis4Stylesheet: { type: Boolean, default: true }, + sichtbar: Boolean + }, + data() { + // The legacy renderer stays the default, so nothing changes until an editor asks + // for the other one. The choice survives a page switch, like the menu switch. + const stored = localStorage.getItem('cms/previewMode'); + const mode = (stored === 'legacy' || stored === 'cis4') ? stored : 'legacy'; + const width = localStorage.getItem('cms/previewWidth'); + return { + mode: mode, + // True while the split handle is dragged. See onSplitterDown. + dragging: false, + cis4Width: CIS4_WIDTHS.hasOwnProperty(width) ? width : 'desktop', + // The CIS4 shell pulls its whole stack before it shows anything, which takes a + // while. Start covered when the frame is the one on screen. + cis4Loading: mode === 'cis4' + }; + }, + watch: { + mode(value) { + if (value === 'cis4') this.startCis4Load(); + else this.stopCis4Poll(); + }, + // A new content, language or version reloads the frame. + cis4PreviewUrl() { + if (this.mode === 'cis4') this.startCis4Load(); + } + }, + 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 second preview renders the content the way CIS4 does: the route loads the + // CIS4 shell, which brings its stylesheets, its Vue app and the contentcomponents. + // CISVUE-Header sets the body class in-frame by itself, and Cis.css hides the CIS4 + // header for it, so the frame shows the content and no navigation. + // version travels as a route segment, because Cms/Content.js already declares that + // prop and the route passes props. sprache and preview travel as query parameters, + // because neither is a prop there. + cis4PreviewUrl() { + let url = FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + '/CisVue/Cms/content/' + this.contentId; + + if (this.version !== null) + url += '/' + this.version; + + url += '?preview=1'; + + if (this.sprache) + url += '&sprache=' + encodeURIComponent(this.sprache); + + return url; + }, + // The overlay sits on the wrapper, so the wrapper carries the width. Otherwise the + // spinner would cover the whole pane while the frame is only 375px wide. + cis4WrapStyle() { + const px = CIS4_WIDTHS[this.cis4Width]; + // content-box on purpose: the frame reads its own window.innerWidth, and with + // the usual border-box the border would eat two pixels. The preset is meant + // to be the width the CIS4 app sees, so it must not depend on the frame. + return px === null + ? '' + : 'width: ' + px + 'px; box-sizing: content-box; max-width: 100%;' + + ' margin: 0 auto;'; + }, + // 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: { + selectMode(value) { + localStorage.setItem('cms/previewMode', value); + this.mode = value; + }, + + // The split handle sits right next to the frame. As soon as the pointer crosses + // into the frame, the frame document takes the mousemove and the mouseup, the + // listeners HorizontalSplit put on the parent window never fire, and the drag + // stops halfway. Turning pointer events off in the frame for the length of the + // drag lets those events through to the page underneath. + // + // Capture phase on purpose: HorizontalSplit.dragStart calls stopPropagation, so a + // listener that waits for the event to bubble back up never sees it at all. + onSplitterDown(event) { + const target = event.target; + if (target && target.closest && target.closest('.horizontalsplitter')) + this.dragging = true; + }, + + onSplitterUp() { + this.dragging = false; + }, + + selectWidth(value) { + localStorage.setItem('cms/previewWidth', value); + this.cis4Width = value; + + // A frame resized by its parent gets no resize event of its own, so Cis.js + // would keep the window width it read at load and stay on that layout. Same + // origin, so tell the frame. That spares a reload of the whole CIS4 stack. + this.$nextTick(() => { + try { + const frame = this.$refs.cis4Frame; + const win = frame && frame.contentWindow; + if (win) win.dispatchEvent(new win.Event('resize')); + } catch (e) { + // Not reachable. The frame keeps the layout it loaded with. + } + }); + }, + + startCis4Load() { + this.stopCis4Poll(); + this.cis4Loading = true; + }, + + // The frame fires load once the CIS4 shell is there, but the shell then fetches the + // content over the API, so the load event alone clears the spinner too early and + // leaves the placeholder text on screen. The frame is same origin, so wait for the + // element the CIS4 renderer writes once it holds the content. + onCis4Load() { + this.stopCis4Poll(); + const started = Date.now(); + + this.cis4Poll = setInterval(() => { + let done = false; + + try { + const doc = this.$refs.cis4Frame && this.$refs.cis4Frame.contentDocument; + done = !!(doc && doc.getElementById('fhc-cms-content')); + } catch (e) { + // Not readable after all. Stop waiting rather than spin forever. + done = true; + } + + if (done || Date.now() - started > CIS4_LOAD_TIMEOUT) { + this.stopCis4Poll(); + this.cis4Loading = false; + } + }, 120); + }, + + stopCis4Poll() { + if (this.cis4Poll) { + clearInterval(this.cis4Poll); + this.cis4Poll = null; + } + }, + + neuLaden() { + if (this.$refs.previewFrame) { + this.$refs.previewFrame.src = this.previewUrl + '&t=' + Date.now(); + } + if (this.$refs.cis4Frame) { + this.startCis4Load(); + this.$refs.cis4Frame.src = this.cis4PreviewUrl + '&t=' + Date.now(); + } + } + }, + mounted() { + // On the window, not the document: HorizontalSplit ends the drag from a listener + // it puts on the window, and a mouseup aimed at the window never reaches a + // listener on the document. The window is the first node of the capture path, so + // it sees everything the document would and that case as well. + window.addEventListener('mousedown', this.onSplitterDown, true); + window.addEventListener('mouseup', this.onSplitterUp, true); + // Backstop. A missed mouseup would leave the frame unclickable. + window.addEventListener('blur', this.onSplitterUp); + }, + beforeUnmount() { + window.removeEventListener('mousedown', this.onSplitterDown, true); + window.removeEventListener('mouseup', this.onSplitterUp, true); + window.removeEventListener('blur', this.onSplitterUp); + this.stopCis4Poll(); + }, + template: ` +
+
+ {{ $p.t('cms/unsichtbarImLivesystem') }} +
+ +
+
+ + +
+ +
+ + +
+
+ + + + +
+ ` +}; diff --git a/public/js/components/Cms/Tabs/Clickstats.js b/public/js/components/Cms/Tabs/Clickstats.js new file mode 100644 index 000000000..63a203202 --- /dev/null +++ b/public/js/components/Cms/Tabs/Clickstats.js @@ -0,0 +1,153 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; + +export default { + name: 'CmsClickstats', + props: { + contentId: Number, + sprache: String, + version: Number, + contentInfo: Object + }, + emits: ['select-content'], + data() { + return { + own: null, + ranked: [], + // The ranking stays absent until the editor asks for it, so an empty table and + // a table that was never fetched stay apart. + rankingLoaded: false, + since: null, + months: 12, + loadingOwn: false, + loadingRanking: false + }; + }, + watch: { + contentId() { this.loadOwn(); }, + sprache() { this.loadOwn(); } + }, + methods: { + // One counting query. Cheap enough to run whenever the tab or the period opens. + loadOwn() { + if (this.contentId == null) return; + + this.loadingOwn = true; + this.$api + .call(ApiCmsAdmin.getClickCount(this.contentId, this.months)) + .then(result => { + this.own = result.data.own; + this.since = result.data.since; + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.loadingOwn = false; }); + }, + + // Groups the whole log, which takes seconds. Only on request. + loadRanking() { + this.loadingRanking = true; + this.$api + .call(ApiCmsAdmin.getClickRanking(this.months, this.sprache)) + .then(result => { + this.ranked = result.data.ranked || []; + this.since = result.data.since; + this.rankingLoaded = true; + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.loadingRanking = false; }); + }, + + // A new period always refreshes the cheap number. The ranking follows only once it + // is on screen, so changing the period never costs the long wait unasked. + onPeriodChange() { + this.loadOwn(); + if (this.rankingLoaded) this.loadRanking(); + }, + + // since arrives as an SQL timestamp. Format it the way the other tabs do. + formatDate(value) { + if (!value) return ''; + const dt = luxon.DateTime.fromSQL(value); + return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value; + }, + + isCurrent(row) { + return row.content_id === this.contentId; + }, + + onContentClick(content_id) { + this.$emit('select-content', content_id); + } + }, + created() { + this.monthOptions = [3, 6, 12, 24, 0]; + }, + mounted() { + this.loadOwn(); + }, + template: ` +
+
+ + + +
+ +
+ {{ since + ? $p.t('cms/klicksSeit', [formatDate(since)]) + : $p.t('cms/klicksGesamt') }} +
+ +
+
{{ $p.t('cms/aufrufeDiesesEintrags') }}
+ {{ own === null ? '-' : own }} +
+ +
{{ $p.t('cms/meistAufgerufeneEintraege') }}
+ + + + + + + + + + + + + + + + + + + + +
{{ $p.t('cms/rang') }}ID{{ $p.t('cms/titel') }}{{ $p.t('cms/aufrufe') }}
{{ idx + 1 }}{{ row.content_id }} + + {{ row.titel || $p.t('cms/ohneTitel') }} + + {{ row.hits }}
+ +
+ {{ $p.t('cms/keineAufrufeImZeitraum') }} +
+
+ ` +}; diff --git a/public/js/components/Cms/Tabs/Content.js b/public/js/components/Cms/Tabs/Content.js new file mode 100644 index 000000000..72441b763 --- /dev/null +++ b/public/js/components/Cms/Tabs/Content.js @@ -0,0 +1,199 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; +import XsdForm from '../Form/XsdForm.js'; +import CmsPreview from '../Preview.js'; +import { CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.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', 'refresh-tree-node', 'select-content'], + data() { + return { + schema: null, + values: {}, + sperre: null, + sichtbar: true, + loading: false, + saving: false + }; + }, + computed: { + // The legacy renderer runs no Vue and draws nothing for a marker. The preview says + // so, but only while the legacy view is the one on screen. + hasContentcomponents() { + return Object.values(this.values || {}).some( + value => typeof value === 'string' && value.indexOf(CONTENTCOMPONENT_ATTR) !== -1 + ); + }, + // The redirect template carries a URL, and its preview opens in a new window + // instead of an iframe. So without the lock nothing on this tab shows the values. + // Read only fields spare the editor a lock that blocks the content for 24 hours. + isRedirect() { + return this.contentInfo?.template_kurzbz === 'redirect'; + }, + lockState() { + if (!this.sperre || this.sperre.gesperrt_uid === null) return 'free'; + if (this.sperre.own) return 'own'; + if (this.sperre.expired) return 'expired'; + return 'foreign'; + }, + lockInfo() { + if (!this.sperre || this.sperre.gesperrt_uid === null) return ''; + + const start = this.formatDateTime(this.sperre.start); + const ende = this.formatDateTime(this.sperre.expires); + + if (this.sperre.own) + return this.$p.t('cms/eigeneSperre', [start, ende]); + + const uid = this.sperre.gesperrt_uid; + if (this.sperre.expired) + return this.$p.t('cms/gesperrtVonSeitAbgelaufen', [uid, start, ende]); + return this.$p.t('cms/gesperrtVonSeit', [uid, start, ende]); + } + }, + watch: { + contentId() { this.loadFormData(); }, + sprache() { this.loadFormData(); }, + version() { this.loadFormData(); } + }, + methods: { + formatDateTime(dateStr) { + if (!dateStr) return this.$p.t('cms/unbekannt'); + const dt = luxon.DateTime.fromSQL(dateStr); + return dt.isValid ? dt.toFormat('dd.MM.yyyy HH:mm') : dateStr; + }, + + 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); + }, + + 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')); + // The form holds the title, and the tree shows it. + this.$emit('refresh-tree-node', this.contentId); + if (this.$refs.preview) this.$refs.preview.neuLaden(); + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.saving = false; }); + } + }, + mounted() { + this.loadFormData(); + }, + template: ` +
+ +
+ ` +}; diff --git a/public/js/components/Cms/Tabs/Delete.js b/public/js/components/Cms/Tabs/Delete.js new file mode 100644 index 000000000..2bbdbb5a7 --- /dev/null +++ b/public/js/components/Cms/Tabs/Delete.js @@ -0,0 +1,154 @@ +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', 'refresh-tree-node', 'select-content'], + data() { + return { + usages: [], + loadingUsage: true + }; + }, + computed: { + hasRestrict() { + return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content'); + }, + // 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.$fhcAlert.alertSuccess(this.$p.t('cms/versionGeloescht')); + this.$emit('reload-content-info'); + // The tree shows the title of the newest version, so the label can change. + this.$emit('refresh-tree-node', this.contentId); + }).catch(this.$fhcAlert.handleSystemError); + }, + + deleteContent() { + this.$fhcAlert.confirm({ + message: this.$p.t('cms/contentLoeschenBestaetigen', [ + this.contentInfo.languages.length, + 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.$fhcAlert.alertSuccess(this.$p.t('cms/contentGeloescht')); + // Children of the deleted entry can reappear under another parent, so the + // whole tree has to be rebuilt here. + 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: ` +
+
{{ $p.t('cms/dieseVersionLoeschen') }}
+ +
+ {{ $p.t('cms/letzteVersionNichtLoeschbar') }} +
+
+ +
+ +
+ +
{{ $p.t('cms/ganzenContentLoeschen') }}
+ +
+
+
+ + + +
+ ` +}; diff --git a/public/js/components/Cms/Tabs/Hierarchy.js b/public/js/components/Cms/Tabs/Hierarchy.js new file mode 100644 index 000000000..af14943f3 --- /dev/null +++ b/public/js/components/Cms/Tabs/Hierarchy.js @@ -0,0 +1,332 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; + +export default { + name: 'CmsHierarchy', + props: { + contentId: Number, + sprache: String, + version: Number, + contentInfo: Object + }, + emits: ['reload-content-info', 'reload-tree', 'refresh-tree-node', 'select-content'], + data() { + return { + parents: [], + childs: [], + possibleChilds: [], + selectedChild: '', + dragIndex: null, + orderBefore: null, + dropped: false, + savingOrder: false + }; + }, + 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.loadParents(); + this.loadChilds(); + this.loadPossibleChilds(); + }, + + loadParents() { + if (this.contentId == null || !this.sprache) return; + this.$api + .call(ApiCmsAdmin.getParents(this.contentId, this.sprache)) + .then(result => { this.parents = result.data || []; }) + .catch(this.$fhcAlert.handleSystemError); + }, + + 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(); + // Only this branch changes, so refresh this branch. + this.$emit('refresh-tree-node', this.contentId); + this.$fhcAlert.alertSuccess(this.$p.t('cms/childHinzugefuegt')); + } 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('refresh-tree-node', this.contentId); + this.$fhcAlert.alertSuccess(this.$p.t('cms/childEntfernt')); + } catch (e) { + this.$fhcAlert.handleSystemError(e); + } + }, + + // Detaches this content from one parent. That is the same row as a child + // assignment, seen from the other end, so it uses the same endpoint. + async removeParent(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.loadParents(); + // The content leaves one branch and may become a root, so rebuild the tree. + this.$emit('reload-tree'); + this.$fhcAlert.alertSuccess(this.$p.t('cms/childEntfernt')); + } 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('refresh-tree-node', this.contentId); + this.$fhcAlert.alertSuccess(this.$p.t('cms/sortierungGespeichert')); + } catch (e) { + this.$fhcAlert.handleSystemError(e); + } + }, + + // --- Drag and drop --- + // The dragged row follows the cursor: every row it enters swaps places with it. + // The drop then only has to write the order that already stands on screen. + + onDragStart(index, event) { + this.dragIndex = index; + this.dropped = false; + this.orderBefore = this.childs.map(c => c.contentchild_id); + event.dataTransfer.effectAllowed = 'move'; + // Firefox starts no drag without a payload. + event.dataTransfer.setData('text/plain', String(index)); + }, + + onDragEnter(index) { + if (this.dragIndex === null || this.dragIndex === index) return; + const moved = this.childs.splice(this.dragIndex, 1)[0]; + this.childs.splice(index, 0, moved); + this.dragIndex = index; + }, + + onDrop() { + this.dropped = true; + }, + + onDragEnd() { + if (this.dragIndex === null) return; + + const before = this.orderBefore; + this.dragIndex = null; + this.orderBefore = null; + + // Escape cancels the drag. Put the rows back. + if (!this.dropped) { + this.applyOrder(before); + return; + } + if (this.orderDiffers(before)) this.saveOrder(before); + }, + + applyOrder(contentchild_ids) { + if (!contentchild_ids) return; + const byId = new Map(this.childs.map(c => [c.contentchild_id, c])); + this.childs = contentchild_ids.map(id => byId.get(id)).filter(c => c); + }, + + orderDiffers(contentchild_ids) { + if (!contentchild_ids) return false; + return this.childs.some((c, i) => c.contentchild_id !== contentchild_ids[i]); + }, + + // One request for the whole order. A child moves over any distance, and the arrow + // endpoint swaps two neighbours, so it would need one request per position. + async saveOrder(before) { + this.savingOrder = true; + try { + await this.$api.call(ApiCmsAdmin.putChildOrder( + this.contentId, + this.childs.map(c => c.contentchild_id) + )); + this.loadChilds(); + this.$emit('refresh-tree-node', this.contentId); + this.$fhcAlert.alertSuccess(this.$p.t('cms/sortierungGespeichert')); + } catch (e) { + this.applyOrder(before); + this.$fhcAlert.handleSystemError(e); + } finally { + this.savingOrder = false; + } + }, + + onContentClick(content_id) { + this.$emit('select-content', content_id); + }, + + isFirst(index) { + return index === 0; + }, + + isLast(index) { + return index === this.childs.length - 1; + } + }, + mounted() { + this.loadAll(); + }, + template: ` +
+
{{ $p.t('cms/folgendeEintraegeSindUebergeordnet') }}
+ + + + + + + + + + + + + + + + +
ID{{ $p.t('cms/titel') }}
{{ parent.content_id }} + + {{ parent.titel || $p.t('cms/ohneTitel') }} + + + +
+ +
+ {{ $p.t('cms/keineUebergeordnetenEintraege') }} +
+ +
{{ $p.t('cms/folgendeEintraegeSindUntergeordnet') }}
+ + + +
+ {{ $p.t('cms/keineUntereintraege') }} +
+ +
+ + +
+
+ ` +}; diff --git a/public/js/components/Cms/Tabs/History.js b/public/js/components/Cms/Tabs/History.js new file mode 100644 index 000000000..46809f1e6 --- /dev/null +++ b/public/js/components/Cms/Tabs/History.js @@ -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: ` +
+
{{ $p.t('cms/versionen') }}
+ +
+
+ {{ $p.t('cms/version') }} {{ v.version }} + + {{ $p.t('cms/unsichtbar') }} + +
+
{{ $p.t('cms/erstelltAmVon', [formatDate(v.insertamum), v.insertvon]) }}
+
+ {{ $p.t('cms/letzteAenderungVonAm', [formatDate(v.updateamum), v.updatevon]) }} +
+
+
+ ` +}; diff --git a/public/js/components/Cms/Tabs/Permissions.js b/public/js/components/Cms/Tabs/Permissions.js new file mode 100644 index 000000000..297bee597 --- /dev/null +++ b/public/js/components/Cms/Tabs/Permissions.js @@ -0,0 +1,143 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; + +export default { + name: 'CmsPermissions', + props: { + contentId: Number, + sprache: String, + version: Number, + contentInfo: Object + }, + emits: ['reload-content-info', 'patch-tree-node', '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 Promise.resolve(); + return this.$api + .call(ApiCmsAdmin.getGruppen(this.contentId)) + .then(result => { + this.gruppen = result.data || []; + this.selectedGruppe = ''; + }) + .catch(this.$fhcAlert.handleSystemError); + }, + + // A group only draws the lock icon. It never moves a node, so the tree needs no + // request here. + patchTree() { + this.$emit('patch-tree-node', { + content_id: this.contentId, + patch: { groups: this.gruppen.map(g => g.gruppe_kurzbz) } + }); + }, + + 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) + ); + await this.loadGruppen(); + this.patchTree(); + this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeHinzugefuegt')); + } 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) + ); + await this.loadGruppen(); + this.patchTree(); + this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeEntfernt')); + } catch (e) { + this.$fhcAlert.handleSystemError(e); + } + } + }, + mounted() { + this.loadAllGruppen(); + this.loadGruppen(); + }, + template: ` +
+ + +
+ {{ $p.t('cms/seiteDarfVonAllenAngezeigtWerden') }} +
+ +
+ + +
+
+ ` +}; diff --git a/public/js/components/Cms/Tabs/Properties.js b/public/js/components/Cms/Tabs/Properties.js new file mode 100644 index 000000000..2d88750d2 --- /dev/null +++ b/public/js/components/Cms/Tabs/Properties.js @@ -0,0 +1,185 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; + +export default { + name: 'CmsProperties', + props: { + contentId: Number, + sprache: String, + version: Number, + contentInfo: Object + }, + emits: ['reload-content-info', 'refresh-tree-node', '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'); + // Title, active flag and organisational unit all show in the tree. + this.$emit('refresh-tree-node', this.contentId); + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.saving = false; }); + } + }, + mounted() { + this.loadLookups(); + this.loadData(); + }, + template: ` +
+
{{ $p.t('cms/giltFuerAlleSprachen') }}
+
+ +
+ +
+ {{ $p.t('cms/vorlageWarnung') }} +
+
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+ +
+ +
+
+ +
+ +
{{ $p.t('cms/giltFuerDieseVersion') }}
+
+ +
+ +
+
+
+
+
+ + +
+
+
+ +
+ +
+ +
+
+ ` +}; diff --git a/public/js/components/Cms/Tree/ContentTree.js b/public/js/components/Cms/Tree/ContentTree.js new file mode 100644 index 000000000..5d8e8c920 --- /dev/null +++ b/public/js/components/Cms/Tree/ContentTree.js @@ -0,0 +1,521 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; +import TreeFilter, { emptyCriteria } from './TreeFilter.js'; +import MenuSwitch from './MenuSwitch.js'; + +// The meta dates are SQL timestamps. Format them like the history tab does. +function formatDay(value) { + if (!value) return ''; + const dt = luxon.DateTime.fromSQL(value); + return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value; +} + +// A day string of the meta value. The inputs deliver YYYY-MM-DD, so compare as text. +function inRange(value, from, to) { + if (!from && !to) return true; + if (!value) return false; + const day = value.substring(0, 10); + if (from && day < from) return false; + if (to && day > to) return false; + return true; +} + +// The meta fields that hold a number. The rest hold an SQL timestamp. +const NUMERIC_FIELDS = ['childcount', 'contentlength']; + +// Sort key of one node. A content without meta sorts last in a descending list. +function sortValue(meta, contentId, field) { + const numeric = NUMERIC_FIELDS.indexOf(field) !== -1; + const m = meta[contentId]; + if (!m) return numeric ? -1 : ''; + if (numeric) return m[field]; + return m[field] || ''; +} + +const TreeNode = { + name: 'CmsTreeNode', + inject: ['treeSelect', 'treeToggle'], + props: { + node: Object, + activeContentId: Number, + expandedKeys: Object, + flat: Boolean, + meta: Object, + metric: String, + // Ancestors of this node, from the top down. Only the nodes of the flat result + // list get one; a child inside the subtree sits under its parent already. + ancestors: Array, + // A node that is context rather than a hit. It and its children draw back. + muted: Boolean + }, + computed: { + // Shows the value the active filter or sort works on, so the order is readable. + badge() { + if (!this.metric || !this.meta) return null; + const m = this.meta[this.node.content_id]; + if (!m) return null; + if (NUMERIC_FIELDS.indexOf(this.metric) !== -1) return String(m[this.metric]); + return formatDay(m[this.metric]) || null; + }, + badgeEmpty() { + return NUMERIC_FIELDS.indexOf(this.metric) !== -1 && this.badge === '0'; + }, + 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; + }, + // The hit keeps its place; the path above it steps in one level at a time. + indent() { + return this.ancestors ? this.ancestors.length : 0; + }, + 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: ` +
  • +
    + + {{ a.titel || a.content_id }} ({{ a.content_id }}) + {{ a.titel || a.content_id }} ({{ a.content_id }}) +
    +
    + + + + + {{ node.titel }} ({{ node.content_id }}) + {{ node.titel }} ({{ node.content_id }}) + + {{ badge }} +
      + +
    +
    +
  • + ` +}; + +export default { + name: 'CmsTree', + components: { + 'cms-tree-node': TreeNode, + 'tree-filter': TreeFilter, + 'menu-switch': MenuSwitch + }, + props: { + activeContentId: Number + }, + emits: ['select-content'], + data() { + const stored = localStorage.getItem('cms/menu'); + return { + menu: (stored === 'content' || stored === 'news') ? stored : 'content', + filter: '', + nodes: [], + loading: false, + expandedKeys: {}, + criteria: emptyCriteria(), + meta: null, + metaLoading: false, + // A flat list drops the context a generic title needs. The toggle puts the + // way down to each hit back, and the choice survives a page switch. + showHierarchy: localStorage.getItem('cms/treeHierarchie') === '1', + ancestors: {}, + ancestorsLoading: false + }; + }, + computed: { + // The news menu is a flat list without the meta fields. + advanced() { + return this.menu === 'content'; + }, + // Every field but the template comes from the meta request. + needsMeta() { + const c = this.criteria; + return c.mine + || !!c.updatedFrom || !!c.updatedTo + || !!c.createdFrom || !!c.createdTo + || c.minChildren !== '' || c.maxChildren !== '' + || c.minLength !== '' || c.maxLength !== '' || c.emptyOnly + || c.sort === 'updateamum' || c.sort === 'insertamum' + || c.sort === 'childcount' || c.sort === 'contentlength'; + }, + narrowing() { + const c = this.criteria; + return !!c.template_kurzbz || c.mine + || !!c.updatedFrom || !!c.updatedTo + || !!c.createdFrom || !!c.createdTo + || c.minChildren !== '' || c.maxChildren !== '' + || c.minLength !== '' || c.maxLength !== '' || c.emptyOnly; + }, + active() { + return this.advanced && (this.narrowing || this.criteria.sort !== ''); + }, + // A hierarchical tree already shows where a node sits. Only a flat list, from a + // search or from a filter, needs the path spelled out. + isFlat() { + return this.active || this.menu === 'news' || !!this.filter; + }, + showPaths() { + return this.showHierarchy && this.isFlat; + }, + // The value the badge shows. The filter drives it, the sort order falls back. + metric() { + const c = this.criteria; + if (!this.active) return null; + if (c.emptyOnly || c.minLength !== '' || c.maxLength !== '') return 'contentlength'; + if (c.minChildren !== '' || c.maxChildren !== '') return 'childcount'; + if (c.updatedFrom || c.updatedTo) return 'updateamum'; + if (c.createdFrom || c.createdTo) return 'insertamum'; + if (c.sort !== '' && c.sort !== 'titel') return c.sort; + return null; + }, + // Flat on purpose: a match sits at any depth, so per-level sorting would hide it. + visibleNodes() { + if (!this.active) return this.nodes; + + const flatList = []; + const walk = (nodes) => { + for (const node of nodes) { + flatList.push(node); + if (node.children && node.children.length) walk(node.children); + } + }; + walk(this.nodes); + + return this.sortNodes(flatList.filter(node => this.matches(node))); + } + }, + 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: { + // Keeps the expanded branches. A reload must not fold the tree back up. + loadTree() { + this.loading = true; + return this.$api + .call(ApiCmsAdmin.getTree(this.menu, this.filter)) + .then(result => { + this.nodes = result.data; + if (this.activeContentId != null) { + this.expandPathTo(this.activeContentId); + } + }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.loading = false; }); + }, + + // --- Public: the parent calls these instead of rebuilding the component --- + + reload() { + // A change moves a date or a child count. Drop the cached meta. + this.meta = null; + if (this.needsMeta) this.loadMeta(); + return this.loadTree(); + }, + + // Replaces one branch. Use it after a change to the children, the title, the + // active flag or the organisational unit of that content. + refreshNode(contentId) { + // The news menu is a flat list of its own. Only a full reload fits it. + if (this.menu === 'news' || contentId == null) return this.loadTree(); + + const slot = this.findSlot(this.nodes, contentId); + // Not in the tree, so the change may add a new root. Rebuild. + if (slot === null) return this.loadTree(); + + return this.$api + .call(ApiCmsAdmin.getSubtree(contentId)) + .then(result => { + if (result.data) { + slot.list.splice(slot.index, 1, result.data); + } else { + slot.list.splice(slot.index, 1); + } + if (this.activeContentId != null) { + this.expandPathTo(this.activeContentId); + } + }) + .catch(this.$fhcAlert.handleSystemError); + }, + + // Writes known fields straight into one node. Groups do not change the structure, + // so they need no request. + patchNode(contentId, patch) { + const slot = this.findSlot(this.nodes, contentId); + if (slot === null) return; + Object.assign(slot.node, patch); + }, + + findSlot(nodes, contentId) { + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].content_id === contentId) { + return { list: nodes, index: i, node: nodes[i] }; + } + if (nodes[i].children && nodes[i].children.length) { + const found = this.findSlot(nodes[i].children, contentId); + if (found !== null) return found; + } + } + return null; + }, + 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); + } + }, + matches(node) { + const c = this.criteria; + + if (c.template_kurzbz && node.template_kurzbz !== c.template_kurzbz) return false; + if (!this.needsMeta) return true; + // The meta arrives after the tree. Hide nothing until it is there. + if (this.meta === null) return true; + + const m = this.meta[node.content_id]; + if (!m) return false; + + if (c.mine && !m.mine) return false; + if (!inRange(m.updateamum, c.updatedFrom, c.updatedTo)) return false; + if (!inRange(m.insertamum, c.createdFrom, c.createdTo)) return false; + if (c.minChildren !== '' && m.childcount < Number(c.minChildren)) return false; + if (c.maxChildren !== '' && m.childcount > Number(c.maxChildren)) return false; + if (c.emptyOnly && m.contentlength !== 0) return false; + if (c.minLength !== '' && m.contentlength < Number(c.minLength)) return false; + if (c.maxLength !== '' && m.contentlength > Number(c.maxLength)) return false; + + return true; + }, + + // Descending for the numbers and the dates: the largest and the newest come first. + sortNodes(list) { + const sort = this.criteria.sort; + if (!sort) return list; + + if (sort === 'titel') { + return list.slice().sort( + (a, b) => (a.titel || '').localeCompare(b.titel || '') + ); + } + + const meta = this.meta || {}; + return list.slice().sort((a, b) => { + const va = sortValue(meta, a.content_id, sort); + const vb = sortValue(meta, b.content_id, sort); + if (va === vb) return 0; + return va < vb ? 1 : -1; + }); + }, + + // One request for the whole tree. Cache it, the filter changes often. + loadMeta() { + if (this.meta !== null || this.metaLoading) return; + + this.metaLoading = true; + this.$api + .call(ApiCmsAdmin.getTreeMeta()) + .then(result => { this.meta = result.data || {}; }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.metaLoading = false; }); + }, + + toggleHierarchy() { + this.showHierarchy = !this.showHierarchy; + localStorage.setItem('cms/treeHierarchie', this.showHierarchy ? '1' : '0'); + + if (!this.showHierarchy) return; + + // The point of the toggle is the context around a hit, and the subtree is half + // of it. A collapsed node would show the way down to the hit and nothing under + // it. Opening the hits leaves every caret free to collapse again. + for (const node of this.visibleNodes) + this.expandedKeys[node.content_id] = true; + + this.loadAncestors(); + }, + + // One request for the whole list. Asking per node would be a request per hit. + loadAncestors() { + if (!this.showPaths) return; + + const ids = this.visibleNodes.map(node => node.content_id); + if (!ids.length) { + this.ancestors = {}; + return; + } + + this.ancestorsLoading = true; + this.$api + .call(ApiCmsAdmin.getAncestors(ids)) + .then(result => { this.ancestors = result.data || {}; }) + .catch(this.$fhcAlert.handleSystemError) + .finally(() => { this.ancestorsLoading = false; }); + }, + + pathFor(contentId) { + return this.showPaths ? (this.ancestors[contentId] || []) : null; + }, + + onCriteria(criteria) { + this.criteria = criteria; + if (this.needsMeta) this.loadMeta(); + this.loadAncestors(); + }, + + onFilter(text) { + this.filter = text; + this.loadTree().then(() => { this.loadAncestors(); }); + }, + createEntry(parentId) { + this.$api + .call(ApiCmsAdmin.postContent(parentId)) + .then(result => { + const newId = result.data.content_id; + this.$fhcAlert.alertSuccess(this.$p.t('cms/eintragAngelegt')); + // A new root is not under any branch, so only a reload shows it. + const refreshed = parentId == null + ? this.loadTree() + : this.refreshNode(parentId); + // Select after the node exists, or expandPathTo finds nothing. + return refreshed.then(() => { + this.$emit('select-content', newId); + }); + }) + .catch(this.$fhcAlert.handleSystemError); + } + }, + mounted() { + this.loadTree(); + }, + template: ` +
    + + +
    + + +
    +
    +
    + + {{ $p.t('cms/nTreffer', [visibleNodes.length]) }} +
    + +
    +
    + +
    +
    + {{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }} +
    +
      + +
    +
    + ` +}; diff --git a/public/js/components/Cms/Tree/MenuSwitch.js b/public/js/components/Cms/Tree/MenuSwitch.js new file mode 100644 index 000000000..ae31f2be9 --- /dev/null +++ b/public/js/components/Cms/Tree/MenuSwitch.js @@ -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: ` +
    + + +
    + ` +}; diff --git a/public/js/components/Cms/Tree/TreeFilter.js b/public/js/components/Cms/Tree/TreeFilter.js new file mode 100644 index 000000000..93599cabb --- /dev/null +++ b/public/js/components/Cms/Tree/TreeFilter.js @@ -0,0 +1,209 @@ +import ApiCmsAdmin from '../../../api/factory/cmsadmin.js'; + +// Empty means "do not narrow by this field". Keep the inputs as strings, the tree parses. +function emptyCriteria() { + return { + template_kurzbz: '', + mine: false, + updatedFrom: '', + updatedTo: '', + createdFrom: '', + createdTo: '', + minChildren: '', + maxChildren: '', + minLength: '', + maxLength: '', + emptyOnly: false, + sort: '' + }; +} + +export { emptyCriteria }; + +export default { + name: 'CmsTreeFilter', + props: { + // The news menu is a flat list of its own. It carries none of these fields. + advanced: { type: Boolean, default: true } + }, + emits: ['filter', 'criteria'], + data() { + return { + text: '', + open: false, + templates: [], + criteria: emptyCriteria() + }; + }, + computed: { + // Counts the narrowing fields only. The sort order hides no entry. + activeCount() { + const c = this.criteria; + let count = 0; + if (c.template_kurzbz) count++; + if (c.mine) count++; + if (c.updatedFrom || c.updatedTo) count++; + if (c.createdFrom || c.createdTo) count++; + if (c.minChildren !== '' || c.maxChildren !== '') count++; + if (c.minLength !== '' || c.maxLength !== '') count++; + if (c.emptyOnly) count++; + return count; + } + }, + methods: { + submit() { + this.$emit('filter', this.text); + }, + toggle() { + this.open = !this.open; + if (this.open && !this.templates.length) this.loadTemplates(); + }, + loadTemplates() { + this.$api + .call(ApiCmsAdmin.getTemplates()) + .then(result => { this.templates = result.data || []; }) + .catch(this.$fhcAlert.handleSystemError); + }, + reset() { + this.text = ''; + // The watcher emits the cleared criteria. + this.criteria = emptyCriteria(); + this.$emit('filter', ''); + } + }, + watch: { + // v-model writes before the watcher runs, so the emitted object is complete. + criteria: { + handler(criteria) { this.$emit('criteria', { ...criteria }); }, + deep: true + } + }, + created() { + this.sortOptions = [ + { value: '', phrase: 'cms/sortHierarchie' }, + { value: 'titel', phrase: 'cms/titel' }, + { value: 'updateamum', phrase: 'cms/sortGeaendert' }, + { value: 'insertamum', phrase: 'cms/sortErstellt' }, + { value: 'childcount', phrase: 'cms/sortChilds' }, + { value: 'contentlength', phrase: 'cms/sortInhaltslaenge' } + ]; + }, + template: ` +
    +
    + + +
    + + + +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + + +
    + + +
    +
    + ` +}; diff --git a/public/js/components/Contentcomponents/ContentchildMenu.js b/public/js/components/Contentcomponents/ContentchildMenu.js new file mode 100644 index 000000000..90a84d60e --- /dev/null +++ b/public/js/components/Contentcomponents/ContentchildMenu.js @@ -0,0 +1,84 @@ +import ApiCms from '../../api/factory/cms.js'; + +/** + * Content component: lists the child pages of a content as a menu. + * + * It replaces a hand written box of content.php links. Those links carry a fixed + * content_id and break when a page moves. This list follows tbl_contentchild, which the + * Kindelemente tab of the CMS admin already maintains. + * + * Without the content-id property the menu shows the children of the page it sits on. + * With it, the menu shows the children of another page. The second form exists because + * the same menu appears on several pages today. + */ +export default { + name: 'ContentchildMenu', + inject: { + contentcomponentContentId: { default: null } + }, + props: { + contentId: { + type: [Number, String], + default: null + } + }, + data() { + return { + kinder: [], + loading: true, + failed: false + }; + }, + computed: { + sprache() { + return this.$p.user_language.value; + }, + // The property wins. Without it the menu takes the page it sits on. + // Vue unwraps an injected ref on the instance, so no .value here. + quelle() { + return this.contentId || this.contentcomponentContentId; + } + }, + methods: { + load() { + if (!this.quelle) + { + this.loading = false; + return; + } + + this.loading = true; + this.failed = false; + this.$api + .call(ApiCms.getContentChilds(this.quelle, this.sprache)) + .then(res => { this.kinder = res.data || []; }) + .catch(() => { this.failed = true; }) + .finally(() => { this.loading = false; }); + } + }, + watch: { + quelle() { this.load(); }, + // The titles come per language, so a language switch needs a new request. + sprache() { this.load(); } + }, + created() { + this.load(); + }, + template: /*html*/ ` +
    +
    ...
    +
    + {{ $p.t('cms/ccLadefehlerMenue') }} +
    + +
      +
    • + + {{ kind.titel }} + +
    • +
    +
    + ` +}; diff --git a/public/js/components/Contentcomponents/ContentcomponentHost.js b/public/js/components/Contentcomponents/ContentcomponentHost.js new file mode 100644 index 000000000..ccd676cba --- /dev/null +++ b/public/js/components/Contentcomponents/ContentcomponentHost.js @@ -0,0 +1,78 @@ +import { components } from './components.js'; +import { scanContentcomponents } from './scan.js'; + +/** + * Renders CMS content HTML and mounts the content components it declares. + * + * Use it in place of a plain `
    `. It owns the root element, the + * change watch and the Teleports, so a host site needs no ref and no lifecycle code. + * + * Teleport, not a separate createApp: the components stay in this app's component tree. + * They therefore share the one $api instance, the one $p instance, the router and the + * provided keys. A createApp per marker would build a second of each. + */ +export default { + name: 'ContentcomponentHost', + props: { + content: { + type: String, + default: '' + }, + // The page this content belongs to. contentchild-menu needs it when the editor + // gave no explicit id. + contentId: { + type: [Number, String], + default: null + } + }, + provide() { + // Vue.computed keeps the value live, the same way Cis.js provides its keys. + return { + contentcomponentContentId: Vue.computed(() => this.contentId) + }; + }, + data() { + return { + markers: [] + }; + }, + methods: { + rescan() { + this.markers = scanContentcomponents(this.$refs.root); + }, + resolve(name) { + return components[name]; + } + }, + watch: { + // Vue replaces the whole v-html subtree when the string changes. A marker that + // still points at a removed node makes Teleport write into a detached element, + // and the component vanishes without an error. Drop the markers first, let the + // new HTML render, then scan again. + content() { + this.markers = []; + this.$nextTick(this.rescan); + } + }, + // A failing content component must not take down the content around it. + errorCaptured(err, instance, info) { + console.error('Contentcomponent failed:', info, err); + return false; + }, + mounted() { + // The components below carry their texts in category cms. $p.t would fetch it by + // itself on the first miss and fill in when it arrives, which shows an empty line + // for that moment. One request here from the single gateway spares that. + this.$p.loadCategory('cms'); + this.rescan(); + }, + beforeUnmount() { + this.markers = []; + }, + template: /*html*/ ` +
    + + + + ` +}; diff --git a/public/js/components/Contentcomponents/DmsDokumente.js b/public/js/components/Contentcomponents/DmsDokumente.js new file mode 100644 index 000000000..fd7d0a0c9 --- /dev/null +++ b/public/js/components/Contentcomponents/DmsDokumente.js @@ -0,0 +1,56 @@ +import ApiCms from '../../api/factory/cms.js'; +import DokumentListe from './DokumentListe.js'; + +/** + * Content component: named DMS documents, in the order the editor gave. + */ +export default { + name: 'DmsDokumente', + components: { + DokumentListe + }, + props: { + dmsIds: { + type: String, + required: true + } + }, + data() { + return { + dokumente: [], + loading: true, + failed: false + }; + }, + methods: { + load() { + this.loading = true; + this.failed = false; + this.$api + .call(ApiCms.getDmsDokumente(this.dmsIds)) + .then(res => { this.dokumente = res.data || []; }) + .catch(() => { this.failed = true; }) + .finally(() => { this.loading = false; }); + } + }, + watch: { + dmsIds() { this.load(); } + }, + created() { + this.load(); + }, + template: /*html*/ ` +
    +
    ...
    +
    + {{ $p.t('cms/ccLadefehlerDokumente') }} +
    + + +
    + ` +}; diff --git a/public/js/components/Contentcomponents/DmsListe.js b/public/js/components/Contentcomponents/DmsListe.js new file mode 100644 index 000000000..dce416e24 --- /dev/null +++ b/public/js/components/Contentcomponents/DmsListe.js @@ -0,0 +1,73 @@ +import ApiCms from '../../api/factory/cms.js'; +import DokumentListe from './DokumentListe.js'; + +/** + * Content component: the published documents of one DMS category. + * + * The low maintenance variant. It lists every document of the category whose newest + * version carries cis_suche, so a replaced or added document appears without an edit in + * the CMS. Use dms-dokumente when the editor must pick single files. + */ +export default { + name: 'DmsListe', + components: { + DokumentListe + }, + props: { + kategorieKurzbz: { + type: String, + required: true + }, + // 0 lists the category itself, which is what a marker without the attribute + // means. Higher takes that many levels of sub categories with it. + tiefe: { + type: Number, + default: 0 + } + }, + data() { + return { + dokumente: [], + zugriff: true, + loading: true, + failed: false + }; + }, + methods: { + load() { + this.loading = true; + this.failed = false; + this.$api + .call(ApiCms.getDmsKategorie(this.kategorieKurzbz, this.tiefe)) + .then(res => { + this.zugriff = res.data.zugriff; + this.dokumente = res.data.dokumente || []; + }) + .catch(() => { this.failed = true; }) + .finally(() => { this.loading = false; }); + } + }, + watch: { + kategorieKurzbz() { this.load(); }, + tiefe() { this.load(); } + }, + created() { + this.load(); + }, + template: /*html*/ ` +
    +
    ...
    +
    + {{ $p.t('cms/ccLadefehlerDokumente') }} +
    + + +
    + ` +}; diff --git a/public/js/components/Contentcomponents/DokumentListe.js b/public/js/components/Contentcomponents/DokumentListe.js new file mode 100644 index 000000000..c0a5c71a1 --- /dev/null +++ b/public/js/components/Contentcomponents/DokumentListe.js @@ -0,0 +1,59 @@ +// Mimetype to Font Awesome 6 icon. The first match wins. +const ICONS = [ + [/pdf/, 'fa-file-pdf'], + [/word|opendocument\.text/, 'fa-file-word'], + [/excel|sheet|csv/, 'fa-file-excel'], + [/powerpoint|presentation/, 'fa-file-powerpoint'], + [/^image\//, 'fa-file-image'], + [/zip|compressed|tar|rar|7z/, 'fa-file-zipper'], + [/^video\//, 'fa-file-video'], + [/^audio\//, 'fa-file-audio'], + [/^text\//, 'fa-file-lines'] +]; + +/** + * Renders a list of DMS documents as download links. + * + * It fetches nothing. dms-liste and dms-dokumente both fill it, so a document reads the + * same whether the editor named a category or single ids. + */ +export default { + name: 'DokumentListe', + props: { + dokumente: { + type: Array, + default: () => [] + } + }, + methods: { + icon(mimetype) { + const typ = (mimetype || '').toLowerCase(); + const treffer = ICONS.find(([muster]) => muster.test(typ)); + return treffer ? treffer[1] : 'fa-file'; + }, + href(dokument) { + return FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/dms.php?id=' + dokument.dms_id; + }, + datum(wert) { + if (!wert) return ''; + const teile = String(wert).slice(0, 10).split('-'); + return teile.length === 3 ? teile[2] + '.' + teile[1] + '.' + teile[0] : ''; + } + }, + template: /*html*/ ` + + ` +}; diff --git a/public/js/components/Contentcomponents/OePersonen.js b/public/js/components/Contentcomponents/OePersonen.js new file mode 100644 index 000000000..d0c52c058 --- /dev/null +++ b/public/js/components/Contentcomponents/OePersonen.js @@ -0,0 +1,85 @@ +import ApiCms from '../../api/factory/cms.js'; +import PersonBlock from './PersonBlock.js'; + +/** + * Content component: every person assigned to an organisation unit. + * + * The low maintenance variant. A new colleague appears without an edit in the CMS, and a + * leaving one disappears. The price is that the editor cannot steer who is shown. Use + * person-block when that control matters. + * + * The function label per person comes from tbl_benutzerfunktion.bezeichnung, + * and falls back to tbl_funktion.beschreibung. + */ +export default { + name: 'OePersonen', + components: { + PersonBlock + }, + props: { + oeKurzbz: { + type: String, + required: true + }, + foto: { + type: Boolean, + default: false + }, + // 0 lists the unit itself, which is what a marker without the attribute means. + // Higher takes that many levels of sub units with it. + tiefe: { + type: Number, + default: 0 + } + }, + data() { + return { + personen: [], + loading: true, + failed: false + }; + }, + methods: { + load() { + this.loading = true; + this.failed = false; + this.$api + .call(ApiCms.getOePersonen(this.oeKurzbz, this.foto, this.tiefe)) + .then(res => { this.personen = res.data || []; }) + .catch(() => { this.failed = true; }) + .finally(() => { this.loading = false; }); + } + }, + watch: { + oeKurzbz() { this.load(); }, + foto() { this.load(); }, + tiefe() { this.load(); } + }, + created() { + this.load(); + }, + template: /*html*/ ` +
    +
    ...
    +
    + {{ $p.t('cms/ccLadefehlerPersonen') }} +
    +
    + {{ $p.t('cms/ccKeinePersonen') }} +
    + +
    + ` +}; diff --git a/public/js/components/Contentcomponents/PersonBlock.js b/public/js/components/Contentcomponents/PersonBlock.js new file mode 100644 index 000000000..bce852f4a --- /dev/null +++ b/public/js/components/Contentcomponents/PersonBlock.js @@ -0,0 +1,53 @@ +/** + * Renders one person the way the teambox blocks do today: name, function, phone, room, + * mail, and an optional photo. + * + * It fetches nothing. person-block and oe-personen both fill it, so one person reads the + * same whether the editor named a UID or an organisation unit. + */ +export default { + name: 'PersonBlock', + props: { + uid: String, + name: String, + funktion: String, + telefon: String, + email: String, + ort: String, + foto: String + }, + computed: { + // tel: takes digits and a leading plus. + telefonLink() { + return this.telefon ? 'tel:' + this.telefon.replace(/[^\d+]/g, '') : null; + }, + fotoQuelle() { + return this.foto ? 'data:image/png;base64,' + this.foto : null; + } + }, + template: /*html*/ ` +
    + +
    + + {{ name }} + + {{ name }} +
    +
    {{ funktion }}
    +
    + T: {{ telefon }} +
    +
    R: {{ ort }}
    +
    + E: {{ email }} +
    +
    + ` +}; diff --git a/public/js/components/Contentcomponents/PersonKontakt.js b/public/js/components/Contentcomponents/PersonKontakt.js new file mode 100644 index 000000000..6dd6572b0 --- /dev/null +++ b/public/js/components/Contentcomponents/PersonKontakt.js @@ -0,0 +1,77 @@ +import ApiCms from '../../api/factory/cms.js'; +import PersonBlock from './PersonBlock.js'; + +/** + * Content component: one person, named by UID. + * + * The flexible variant. The editor decides who appears and in which order by placing + * several markers. Use oe-personen instead when the list should follow the organizational structure. + * + * The function label comes from the marker, not from the database, because the database + * name does not always fit. It needs no phrase key: the CMS holds one content per + * language, so the German page carries the German label and the English page the English + * one. + */ +export default { + name: 'PersonKontakt', + components: { + PersonBlock + }, + props: { + uid: { + type: String, + required: true + }, + funktion: { + type: String, + default: '' + }, + foto: { + type: Boolean, + default: false + } + }, + data() { + return { + person: null, + loading: true, + failed: false + }; + }, + methods: { + load() { + this.loading = true; + this.failed = false; + this.$api + .call(ApiCms.getPerson(this.uid, this.foto)) + .then(res => { this.person = res.data; }) + .catch(() => { this.failed = true; }) + .finally(() => { this.loading = false; }); + } + }, + watch: { + uid() { this.load(); }, + foto() { this.load(); } + }, + created() { + this.load(); + }, + template: /*html*/ ` +
    +
    ...
    +
    + {{ $p.t('cms/ccLadefehlerPerson') }} +
    + +
    + ` +}; diff --git a/public/js/components/Contentcomponents/README.md b/public/js/components/Contentcomponents/README.md new file mode 100644 index 000000000..83df98804 --- /dev/null +++ b/public/js/components/Contentcomponents/README.md @@ -0,0 +1,113 @@ +# Contentcomponents + +Vue components that CMS editors can place inside CMS content. The editor inserts a marker +div, CIS4 finds it and teleports a real component into it. The component fetches its own +data and has everything a normal component has: `$api`, `$p`, the router, the provided +keys. + +## The marker + +```html +
    +``` + +Editors do not write this. The **Contentcomponent** button in TinyMCE 5 opens a dialog +built from `catalog.js`. + +## Add a component + +| File | What to add | +|---|---| +| `MeinBaustein.js` | the component | +| `catalog.js` | an entry in `catalog`: name, label, props | +| `components.js` | the import plus `'mein-baustein': MeinBaustein` | + +Catalog and map are two hand-written lists. `components.js` logs a mismatch on load. + +### The component + +Copy `DmsListe.js`, the shortest complete example. Requirements: + +- It stands alone. No parent props, no route params. +- It fetches in `created()`, and again from a `watch` on every prop. +- It handles loading, failure and empty. +- Root class `fhc-contentcomponent-`. +- `template` string. There is no `.vue` build. + +To get the id of the page the marker sits on: + +```js +inject: { contentcomponentContentId: { default: null } } +``` + +`ContentchildMenu.js` shows the full pattern. + +Presentational helpers belong in this folder but not in the catalog. See `PersonBlock.js` +and `DokumentListe.js`. + +### The catalog entry + +```js +{ + name: 'mein-baustein', // data-fhc-component, and the key in components.js + labelPhrase: 'ccMeinBaustein', // phrase in category cms, shown in the picker + props: { + 'mein-wert': { type: 'string', required: true, labelPhrase: 'ccMeinWert' } + } +} +``` + +The catalog is an allow-list. `scan.js` reads only the attributes declared here and drops +every other one. + +| Prop key | Meaning | +|---|---| +| `type` | `string`, `number` or `boolean`. Anything else stays a string. | +| `required` | A missing attribute makes `scan.js` skip the whole marker. The dialog does not enforce it. | +| `label` | Field label in the dialog. It defaults to the attribute name. | +| `source` | Fills the field from a server list: `oe`, `person`, `dmskategorie`. | +| `picker` | Only `'dms'`. Adds category and document selects that append ids to the field. | + +## Gotchas + +- **kebab-case attribute names.** The HTML parser lowercases them, so `data-meinWert` does + not survive. Vue maps `mein-wert` to the prop `meinWert`. +- **`boolean` means `"true"` or `"1"`.** Everything else is false, and the dialog never + writes a false into the marker. +- **Catalog name is not the file name.** `person-block` resolves to `PersonKontakt.js`. +- **The marker is a `div`, never a custom tag.** TinyMCE 3 in the legacy admin treats an + unknown tag as inline and can split it on save. +- **20 components per content** — `CONTENTCOMPONENT_MAX` in `catalog.js`. +- **Content is never compiled as a Vue template.** An editor cannot write expressions. +- **News renders no components on purpose.** A list shows many contents at once, and every + component would start its own request. +- A throwing component does not take the content down. The host catches it and logs it. + +## Server data + +Model, controller, factory. Follow `getDmsKategorie`. + +1. `application/models/content/Dms_model.php` — the query. +2. `application/controllers/api/frontend/v1/Cms.php` — the method, plus + `'getMeineDaten' => self::PERM_LOGGED` in the constructor. Validate with + `form_validation`. +3. `public/js/api/factory/cms.js` — the request function. + +A missing entitlement answers with success and an empty list, not with an error. The +reader gets no hint that something is hidden. + +## A new dialog source + +1. `CmsAdmin.php` — the endpoint, plus `['basis/cms:r']` in the constructor. +2. `cmsadmin.js` — the request function. +3. `ContentcomponentDialog.js` — an entry in `SOURCES`: + +```js +meineliste: { + request: () => ApiCmsAdmin.getMeineListe(), + item: e => ({ value: e.kurzbz, text: e.bezeichnung }) +} +``` + +4. `source: 'meineliste'` in the catalog prop. + diff --git a/public/js/components/Contentcomponents/catalog.js b/public/js/components/Contentcomponents/catalog.js new file mode 100644 index 000000000..76ab6de98 --- /dev/null +++ b/public/js/components/Contentcomponents/catalog.js @@ -0,0 +1,132 @@ +/** + * Catalog of the components that may be placed inside CMS content. + * + * A component is available to CMS editors only if it has an entry here. An arbitrary + * project component cannot work in content: it expects props from a parent, inject keys + * and route params. Content supplies attribute strings only. A component listed here is + * written for content use: lightweight, one fetch, props chosen in the CMS. + * + * Marker convention in the content HTML: + * + *
    + * + * A div is used instead of a custom tag on purpose. TinyMCE 5 (CIS4 admin) and TinyMCE 3 + * (legacy admin) both treat a div as a block. An unknown tag counts as inline and the + * legacy admin can split or nest it on save, which would corrupt shared content. + */ + +// Class on every marker. TinyMCE uses it for noneditable and for the editor styling. +export const CONTENTCOMPONENT_CLASS = 'fhc-contentcomponent'; + +// Attribute holding the component name. +export const CONTENTCOMPONENT_ATTR = 'data-fhc-component'; + +// Upper bound per content. A paste accident must not start dozens of requests. +export const CONTENTCOMPONENT_MAX = 20; + +/** + * One entry per available component. + * + * name value of data-fhc-component, and the key in components.js + * labelPhrase phrase in category cms, shown in the insert dialog + * props attribute name (kebab-case) => { type, required, labelPhrase, source } + * + * Attribute names must be kebab-case. The HTML parser lowercases every attribute name, + * so camelCase would not survive. Vue maps the kebab key back to the camelCase prop. + * + * TODO evaluate where "source" belongs. It tells the insert dialog how to fill the field. + * Only 'text' and 'oe' exist today. A growing list may deserve its own place. + */ +export const catalog = [ + { + name: 'oe-personen', + labelPhrase: 'ccOePersonen', + props: { + 'oe-kurzbz': { + type: 'string', + required: true, + labelPhrase: 'ccOrganisationseinheit', + source: 'oe' + }, + 'foto': { + type: 'boolean', + required: false, + labelPhrase: 'ccFotosAnzeigen' + }, + 'tiefe': { + type: 'number', + required: false, + labelPhrase: 'ccTiefeOe' + } + } + }, + { + name: 'person-block', + labelPhrase: 'ccPersonBlock', + props: { + 'uid': { + type: 'string', + required: true, + labelPhrase: 'ccPerson', + source: 'person' + }, + 'funktion': { + type: 'string', + required: false, + labelPhrase: 'ccFunktionsbezeichnung' + }, + 'foto': { + type: 'boolean', + required: false, + labelPhrase: 'ccFotoAnzeigen' + } + } + }, + { + name: 'dms-dokumente', + labelPhrase: 'ccDmsDokumente', + props: { + 'dms-ids': { + type: 'string', + required: true, + labelPhrase: 'ccDmsIds', + // Adds a category filter and a document list to the dialog. Choosing a + // document appends its id to this field. + picker: 'dms' + } + } + }, + { + name: 'dms-liste', + labelPhrase: 'ccDmsListe', + props: { + 'kategorie-kurzbz': { + type: 'string', + required: true, + labelPhrase: 'ccDmsKategorie', + source: 'dmskategorie' + }, + 'tiefe': { + type: 'number', + required: false, + labelPhrase: 'ccTiefeDms' + } + } + }, + { + name: 'contentchild-menu', + labelPhrase: 'ccContentchildMenu', + props: { + 'content-id': { + type: 'number', + required: false, + labelPhrase: 'ccContentId' + } + } + } +]; + +export function getDescriptor(name) +{ + return catalog.find(entry => entry.name === name) || null; +} diff --git a/public/js/components/Contentcomponents/components.js b/public/js/components/Contentcomponents/components.js new file mode 100644 index 000000000..bca04bb2e --- /dev/null +++ b/public/js/components/Contentcomponents/components.js @@ -0,0 +1,33 @@ +import { catalog } from './catalog.js'; +import OePersonen from './OePersonen.js'; +import DmsListe from './DmsListe.js'; +import DmsDokumente from './DmsDokumente.js'; +import PersonKontakt from './PersonKontakt.js'; +import ContentchildMenu from './ContentchildMenu.js'; + +/** + * Component name => component. + * + * Static imports. At the current scale this is simpler than a dynamic import and it + * survives a rollup bundle. Switch to `(await import(file)).default`, the way + * Dashboard/Item.js loads a widget, once the catalog gets long. + */ +export const components = { + 'oe-personen': OePersonen, + 'person-block': PersonKontakt, + 'dms-liste': DmsListe, + 'dms-dokumente': DmsDokumente, + 'contentchild-menu': ContentchildMenu +}; + +// The catalog and this map are written by hand and must name the same set. +for (const entry of catalog) +{ + if (!components[entry.name]) + console.error('Contentcomponent "' + entry.name + '" is in the catalog but has no component.'); +} +for (const name of Object.keys(components)) +{ + if (!catalog.some(entry => entry.name === name)) + console.error('Contentcomponent "' + name + '" has a component but no catalog entry.'); +} diff --git a/public/js/components/Contentcomponents/scan.js b/public/js/components/Contentcomponents/scan.js new file mode 100644 index 000000000..cf1be66f0 --- /dev/null +++ b/public/js/components/Contentcomponents/scan.js @@ -0,0 +1,135 @@ +import { + catalog, + getDescriptor, + CONTENTCOMPONENT_ATTR, + CONTENTCOMPONENT_MAX +} from './catalog.js'; + +// Runs over the lifetime of the page. Two identical markers in one content therefore get +// two distinct keys, which Vue needs to keep their Teleports apart. +let keyCounter = 0; + +// One log line per problem, not one per render. +const reported = new Set(); + +function report(message) +{ + if (reported.has(message)) + return; + reported.add(message); + console.error('Contentcomponent: ' + message); +} + +/** + * Turns one attribute string into the declared type. Returns undefined on a bad value. + */ +function coerce(raw, spec) +{ + switch (spec.type) + { + case 'number': + { + const value = Number(raw); + return (raw === '' || Number.isNaN(value)) ? undefined : value; + } + case 'boolean': + return raw === 'true' || raw === '1'; + default: + return raw; + } +} + +/** + * Reads the props of one marker. Only an attribute named in the schema is read. Every + * other attribute is dropped. This allow-list is the boundary between the content and + * the component. + */ +function readProps(el, descriptor) +{ + const props = {}; + + for (const [name, spec] of Object.entries(descriptor.props || {})) + { + const raw = el.getAttribute('data-' + name); + + if (raw === null) + { + if (spec.required) + { + report('"' + descriptor.name + '" misses the required property "' + name + '".'); + return null; + } + continue; + } + + const value = coerce(raw, spec); + if (value === undefined) + { + report('"' + descriptor.name + '" got the value "' + raw + '" for "' + name + + '", which is not a ' + spec.type + '.'); + return null; + } + + props[name] = value; + } + + return props; +} + +/** + * Finds every marker below root and returns the ones that can be rendered. + * An unknown name or a bad property is logged and skipped, never shown to a reader. + * + * @param {Element} root + * @return {Array<{el: Element, key: string, name: string, props: Object}>} + */ +export function scanContentcomponents(root) +{ + if (!root) + return []; + + const elements = Array.from(root.querySelectorAll('[' + CONTENTCOMPONENT_ATTR + ']')); + + if (elements.length > CONTENTCOMPONENT_MAX) + { + report('a content holds ' + elements.length + ' components. Only the first ' + + CONTENTCOMPONENT_MAX + ' are rendered.'); + } + + const markers = []; + + for (const el of elements.slice(0, CONTENTCOMPONENT_MAX)) + { + const name = el.getAttribute(CONTENTCOMPONENT_ATTR); + const descriptor = getDescriptor(name); + + if (!descriptor) + { + report('"' + name + '" is not in the catalog. Known: ' + + catalog.map(entry => entry.name).join(', ') + '.'); + continue; + } + + const props = readProps(el, descriptor); + if (props === null) + continue; + + // A fresh marker gets a key and loses its inner HTML. Teleport adds its children + // and keeps the existing ones, so a fallback text would stay under the component. + // A marker that already carries a key holds a mounted component. Do not clear it. + if (!el.dataset.fhcComponentKey) + { + el.dataset.fhcComponentKey = String(++keyCounter); + el.innerHTML = ''; + } + + markers.push({ + el: el, + key: el.dataset.fhcComponentKey, + name: name, + props: props + }); + } + + return markers; +} diff --git a/public/js/components/DashboardWidget/News.js b/public/js/components/DashboardWidget/News.js index 103bc4fa1..46904ff99 100644 --- a/public/js/components/DashboardWidget/News.js +++ b/public/js/components/DashboardWidget/News.js @@ -256,6 +256,7 @@ export default {