diff --git a/application/config/cms.php b/application/config/cms.php index 5561cabac..2e7ad46c8 100644 --- a/application/config/cms.php +++ b/application/config/cms.php @@ -10,5 +10,12 @@ $config['default_oe_kurzbz'] = 'etw'; // Lifetime of an edit lock in hours, counted from tbl_contentlog.start. Must be > 0. $config['lock_ttl_hours'] = 24; -// Default period of the tree click count in months, when the caller sends none. +// 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/controllers/Cms.php b/application/controllers/Cms.php index a845194f4..353832231 100644 --- a/application/controllers/Cms.php +++ b/application/controllers/Cms.php @@ -7,11 +7,17 @@ class Cms extends Auth_Controller public function __construct() { parent::__construct(['index' => 'basis/cms:r']); + $this->load->library('PermissionLib'); + $this->config->load('cms'); $this->loadPhrases(['global', 'ui', 'cms']); } public function index() { - $this->load->view('Cms.php'); + // 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 bba1a1861..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 @@ -81,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); @@ -218,28 +225,46 @@ class Cms extends FHCAPI_Controller 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; - // '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_kurzbz); - $rows = $this->getDataOrTerminateWithError($rows) ?: array(); - - // The model returns the aktiv flag but does not filter on it. - $rows = array_values(array_filter($rows, function ($row) { - return $row->aktiv; - })); + // 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 ($rows as $row) + + foreach ($oeRows as $oe) { - $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; + // '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)); @@ -292,8 +317,28 @@ class Cms extends FHCAPI_Controller $dokumente = array(); if ($erlaubt) { - $dokumente = $this->DmsModel->getKategorieDokumente($kategorie_kurzbz); - $dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array(); + // 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( @@ -451,6 +496,23 @@ class Cms extends FHCAPI_Controller * @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'); diff --git a/application/controllers/api/frontend/v1/CmsAdmin.php b/application/controllers/api/frontend/v1/CmsAdmin.php index c40f2b7dd..c05df8419 100644 --- a/application/controllers/api/frontend/v1/CmsAdmin.php +++ b/application/controllers/api/frontend/v1/CmsAdmin.php @@ -20,7 +20,10 @@ class CmsAdmin extends FHCAPI_Controller 'getMitarbeiter' => ['basis/cms:r'], 'getDmsKategorieDokumente' => ['basis/cms:r'], 'getUsage' => ['basis/cms:r'], - 'getClickCounts' => ['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'], @@ -52,7 +55,7 @@ class CmsAdmin extends FHCAPI_Controller $filter = $this->input->get('filter', TRUE); $entitledResult = $this->cmsadminlib->getEntitledOe(); - $entitledOe = isError($entitledResult) ? [] : getData($entitledResult); + $entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? []; $nodes = []; @@ -226,6 +229,10 @@ class CmsAdmin extends FHCAPI_Controller $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); } @@ -253,11 +260,161 @@ class CmsAdmin extends FHCAPI_Controller $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; + /** - * Views per content for the tree ranking. Empty if LOG_CONTENT is off. + * Ancestors of the given contents, keyed by content_id and ordered from the top down. */ - public function getClickCounts() + 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'); @@ -265,29 +422,32 @@ class CmsAdmin extends FHCAPI_Controller $this->terminateWithValidationErrors($this->form_validation->error_array()); $this->load->model('system/Webservicelog_model', 'WebservicelogModel'); - $this->config->load('cms'); - $months = $this->input->get('months'); - if ($months === null || $months === '') - $months = $this->config->item('clickstats_months'); - $months = (int) $months; + list($months, $since) = $this->clickstatsPeriod(); - // 0 counts the whole log. That is the slowest case and stays an explicit choice. - $since = ($months > 0) - ? date('Y-m-d H:i:s', strtotime('-' . $months . ' months')) - : null; + $sprache = $this->input->get('sprache', TRUE); + if (empty($sprache)) + $sprache = DEFAULT_LANGUAGE; - $result = $this->WebservicelogModel->getContentClickCounts($since); + $result = $this->WebservicelogModel->getContentClickCounts( + $since, $sprache, (int) $this->config->item('clickstats_limit') + ); $rows = $this->getDataOrTerminateWithError($result); - $counts = []; + $ranked = []; foreach ((array) $rows as $row) - $counts[(int) $row->request_id] = (int) $row->hits; + { + $ranked[] = [ + 'content_id' => (int) $row->content_id, + 'titel' => $row->titel, + 'hits' => (int) $row->hits + ]; + } $this->terminateWithSuccess([ 'since' => $since, 'months' => $months, - 'counts' => $counts + 'ranked' => $ranked ]); } @@ -316,9 +476,7 @@ class CmsAdmin extends FHCAPI_Controller { $this->load->model('system/Sprache_model', 'SpracheModel'); $this->SpracheModel->addSelect('sprache, bezeichnung'); - // DEVIATION: admin.php offers every row of tbl_sprache. The column content marks a - // language as relevant for the content language choice, so a retired language stays - // out of the list. Phrasen.php filters the same table the same way. + $result = $this->SpracheModel->loadWhere(['content' => true]); $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); } diff --git a/application/controllers/api/frontend/v1/CmsAdminStruktur.php b/application/controllers/api/frontend/v1/CmsAdminStruktur.php index b523c8663..0145c2d5e 100644 --- a/application/controllers/api/frontend/v1/CmsAdminStruktur.php +++ b/application/controllers/api/frontend/v1/CmsAdminStruktur.php @@ -10,12 +10,14 @@ class CmsAdminStruktur extends FHCAPI_Controller 'getGruppen' => ['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'] + 'putChildSort' => ['basis/cms:rw'], + 'putChildOrder' => ['basis/cms:rw'] ]); $this->load->library('PermissionLib'); @@ -64,6 +66,24 @@ class CmsAdminStruktur extends FHCAPI_Controller $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'); @@ -146,10 +166,7 @@ class CmsAdminStruktur extends FHCAPI_Controller $sortResult = $this->ContentchildModel->getMaxSort($content_id); $sort = $this->getDataOrTerminateWithError($sortResult) + 1; - - // LEGACY-QUIRK: tbl_contentchild has no unique index on (content_id, - // child_content_id). The legacy code does not check for a duplicate, so a child can be - // attached more than once. Kept as a functional copy. See section 3 of the contract. + $result = $this->ContentchildModel->insert([ 'content_id' => $content_id, 'child_content_id' => $this->input->post('child_content_id'), @@ -191,10 +208,58 @@ class CmsAdminStruktur extends FHCAPI_Controller 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'), - $this->input->post('direction') + $direction ); - $this->terminateWithSuccess($this->getDataOrTerminateWithError($result)); + + 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 index 3188cebdb..ca0f74cd3 100644 --- a/application/libraries/CmsAdminLib.php +++ b/application/libraries/CmsAdminLib.php @@ -35,10 +35,7 @@ class CmsAdminLib return success($oe); } - - // LEGACY-QUIRK: the legacy code checks the organisational unit only when it loads the - // page. It does not check on each write. This method exists, but it is called only - // where the legacy code calls it. See Q7 in the contract. + /** * @param int $content_id * @return object success(bool) @@ -188,9 +185,7 @@ class CmsAdminLib 'sichtbar' => true, 'titel' => $source->titel, 'content' => $source->content, - // The review feature is gone from this admin, but the columns stay and the - // legacy admin still writes them. A copied row must not inherit the review - // stamp of its source, so we clear it here. + // The review feature is gone from this admin, but the columns stay 'reviewvon' => null, 'reviewamum' => null, 'gesperrt_uid' => null, @@ -232,9 +227,7 @@ class CmsAdminLib 'sichtbar' => false, 'titel' => $source->titel, 'content' => $source->content, - // The review feature is gone from this admin, but the columns stay and the - // legacy admin still writes them. A copied row must not inherit the review - // stamp of its source, so we clear it here. + // The review feature is gone from this admin, but the columns stay 'reviewvon' => null, 'reviewamum' => null, 'gesperrt_uid' => null, @@ -250,7 +243,9 @@ class CmsAdminLib } /** - * @param array $daten fields from contract 7.7 + * 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) @@ -277,11 +272,11 @@ class CmsAdminLib 'updatevon' => $uid ]); - // LEGACY-QUIRK: prefs_save sets updateamum and updatevon in tbl_content only. - // It does not touch tbl_contentsprache. Kept as a functional copy. $this->ci->ContentspracheModel->update($row->contentsprache_id, [ 'titel' => $daten['titel'], - 'sichtbar' => $daten['sichtbar'] + 'sichtbar' => $daten['sichtbar'], + 'updateamum' => $now, + 'updatevon' => $uid ]); $this->ci->db->trans_complete(); @@ -292,9 +287,9 @@ class CmsAdminLib return success(true); } - // LEGACY-QUIRK: the legacy XSDFormPrinter_XML branch checks neither the permission + // 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. See Q3 in the contract. + // itself is unprotected. /** * @param int $content_id * @param string $sprache @@ -342,9 +337,7 @@ class CmsAdminLib return success(true); } - - // DEVIATION: content::sperren overwrites a live lock without a check (Q2). Locking now - // refuses a live foreign lock and takes over only an expired one. + /** * Locks a version for the current user, or takes over an expired lock. * @param int $contentsprache_id @@ -373,8 +366,7 @@ class CmsAdminLib } $this->ci->db->trans_start(); - - // Close every open entry: a take-over ends the previous one, and the legacy leaks them. + $this->ci->ContentlogModel->closeOpenEntries($contentsprache_id); $logResult = $this->ci->ContentlogModel->insert([ @@ -399,8 +391,8 @@ class CmsAdminLib return success(true); } - // DEVIATION: content::freigabeUser releases ALL locks of the user (Q1). Releasing now - // ends one lock. The uid predicate stops a user releasing a lock of somebody else. + // 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 @@ -446,7 +438,7 @@ class CmsAdminLib * @param int $content_id * @param string $sprache * @param int $version - * @return object success(array) lock state per contract 7.8 + * @return object success(array) gesperrt_uid, start, expires, own, expired, may_force */ public function getLockState($content_id, $sprache, $version) { @@ -455,8 +447,8 @@ class CmsAdminLib return $versionResult; $row = getData($versionResult); - // LEGACY-QUIRK: admin.php inserts an empty string, releasing writes NULL. Both mean - // free. The contract reports NULL. + // 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; @@ -495,8 +487,7 @@ class CmsAdminLib return date('Y-m-d H:i:s', strtotime($logEntry->start) + $ttlHours * 3600); } - // DEVIATION: the legacy holds a lock until its owner or a superuser releases it, so a - // forgotten lock blocks a page forever. Locks now age out after lock_ttl_hours. + // 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 @@ -512,9 +503,7 @@ class CmsAdminLib return $expires === null || strtotime($expires) < time(); } - - // DEVIATION: admin.php ignores the return value of deleteContent and tells the user - // nothing. This code reports the error. See Q5 in the contract. + /** * @param int $content_id * @return object success(true) or error 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/models/content/Content_model.php b/application/models/content/Content_model.php index 0e4f082be..1578b529d 100644 --- a/application/models/content/Content_model.php +++ b/application/models/content/Content_model.php @@ -398,6 +398,63 @@ class Content_model extends DB_Model 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 diff --git a/application/models/content/Contentchild_model.php b/application/models/content/Contentchild_model.php index 04d9f8350..8253c7d54 100644 --- a/application/models/content/Contentchild_model.php +++ b/application/models/content/Contentchild_model.php @@ -1,6 +1,9 @@ execReadOnlyQuery($query, [$sprache, $content_id]); + } + /** * Children of a content as a reader may see them. * @@ -185,8 +210,10 @@ class Contentchild_model extends DB_Model 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($direction === 'up' ? 'cms/bereitsGanzOben' : 'cms/bereitsGanzUnten'); + return error(self::NO_NEIGHBOUR); $neighbour = $neighbourData[0]; @@ -210,6 +237,111 @@ class Contentchild_model extends DB_Model 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 diff --git a/application/models/content/Contentgruppe_model.php b/application/models/content/Contentgruppe_model.php index aa68be8ad..f853a5387 100644 --- a/application/models/content/Contentgruppe_model.php +++ b/application/models/content/Contentgruppe_model.php @@ -20,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) { @@ -32,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 diff --git a/application/models/content/Dms_model.php b/application/models/content/Dms_model.php index afcd1ffe9..a0c19bf9c 100644 --- a/application/models/content/Dms_model.php +++ b/application/models/content/Dms_model.php @@ -232,4 +232,39 @@ class Dms_model extends DB_Model 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/system/Webservicelog_model.php b/application/models/system/Webservicelog_model.php index f6bae3f68..052a45eb7 100644 --- a/application/models/system/Webservicelog_model.php +++ b/application/models/system/Webservicelog_model.php @@ -14,18 +14,59 @@ class Webservicelog_model extends DB_Model } /** - * Counts the content views per content since a date. + * 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 = null) + 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 request_id, COUNT(*) AS hits + SELECT COUNT(*) AS hits FROM system.tbl_webservicelog - WHERE webservicetyp_kurzbz = ? + WHERE webservicetyp_kurzbz = ? AND request_id = ? '; - $params = ['content']; + $params = ['content', (string) $content_id]; if ($since !== null) { @@ -33,8 +74,10 @@ class Webservicelog_model extends DB_Model $params[] = $since; } - $query .= ' GROUP BY request_id'; + $result = $this->execReadOnlyQuery($query, $params); + if (isError($result)) + return $result; - return $this->execReadOnlyQuery($query, $params); + 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 index 855ece18a..c14215316 100644 --- a/application/views/Cms.php +++ b/application/views/Cms.php @@ -29,6 +29,7 @@ cms-root="" auth-uid="" default-language="" + > load->view('templates/FHC-Footer', $includesArray); ?> diff --git a/public/css/Cms.css b/public/css/Cms.css index 1e5ba131d..a8674790d 100644 --- a/public/css/Cms.css +++ b/public/css/Cms.css @@ -54,3 +54,44 @@ body { 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 6c0aa00c5..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 } : {}) } }; }, @@ -55,11 +57,16 @@ export default { }; }, // Used by the CMS content component "oe-personen". - getOePersonen(oe_kurzbz, foto) { + // 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 } : {}) } + params: { + oe_kurzbz, + ...(foto ? { foto: 1 } : {}), + ...(tiefe ? { tiefe } : {}) + } }; }, // Used by the CMS content component "person-block". @@ -79,11 +86,13 @@ export default { }; }, // Used by the CMS content component "dms-liste". - getDmsKategorie(kategorie_kurzbz) { + // 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 } + params: { kategorie_kurzbz, ...(tiefe ? { tiefe } : {}) } }; }, // Used by the CMS content component "contentchild-menu". diff --git a/public/js/api/factory/cmsadmin.js b/public/js/api/factory/cmsadmin.js index 711f91e69..b4cb88401 100644 --- a/public/js/api/factory/cmsadmin.js +++ b/public/js/api/factory/cmsadmin.js @@ -30,11 +30,38 @@ export default { params: { content_id, sprache, version } }; }, - getClickCounts(months) { + // Feeds the tree filter: child count, insertamum, updateamum and the own-edit flag. + getTreeMeta() { return { method: 'get', - url: '/api/frontend/v1/CmsAdmin/getClickCounts', - params: { months } + 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() { @@ -208,6 +235,14 @@ export default { 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', @@ -249,5 +284,13 @@ export default { 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 index a75f7efe1..324fc5e46 100644 --- a/public/js/apps/Cms.js +++ b/public/js/apps/Cms.js @@ -13,7 +13,7 @@ const router = VueRouter.createRouter({ component: CmsAdmin, children: [ { - // Only the content_id is required. CmsAdmin fills the missing parts + // 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?', 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/Cms/CmsAdmin.js b/public/js/components/Cms/CmsAdmin.js index 8ac68e2cf..cb374dba5 100644 --- a/public/js/components/Cms/CmsAdmin.js +++ b/public/js/components/Cms/CmsAdmin.js @@ -4,16 +4,17 @@ import ContentHeader from './Header/ContentHeader.js'; import Properties from './Tabs/Properties.js'; import ContentTab from './Tabs/Content.js'; import Permissions from './Tabs/Permissions.js'; -import Children from './Tabs/Children.js'; +import 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, - children: Children, + hierarchy: Hierarchy, history: History, delete: Delete }; @@ -22,11 +23,15 @@ const TABS = [ { key: 'properties', phrase: 'cms/tabEigenschaften' }, { key: 'content', phrase: 'cms/tabInhalt' }, { key: 'permissions', phrase: 'cms/tabRechte' }, - { key: 'children', phrase: 'cms/tabChilds' }, + { 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: { @@ -38,7 +43,8 @@ export default { props: { cmsRoot: String, authUid: String, - defaultLanguage: { type: String, default: 'German' } + defaultLanguage: { type: String, default: 'German' }, + clickstats: { type: Boolean, default: false } }, data() { return { @@ -51,10 +57,17 @@ export default { }; }, computed: { + tabs() { + return this.clickstats ? TABS.concat([CLICKSTATS_TAB]) : TABS; + }, + tabComponents() { + return this.clickstats + ? { ...TAB_COMPONENTS, clickstats: Clickstats } + : TAB_COMPONENTS; + }, activeTabComponent() { - return TAB_COMPONENTS[this.tab] || null; + return this.tabComponents[this.tab] || null; }, - // Two switches in flight can resolve out of order, so block the view during one. busy() { return this.pending > 0; } @@ -65,7 +78,8 @@ export default { const id = params.content_id ? Number(params.content_id) : null; const sprache = params.sprache || null; const version = params.version ? Number(params.version) : null; - const tab = params.tab || 'properties'; + // 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) { @@ -171,7 +185,7 @@ export default { }, // Three grades of tree update. Pick the cheapest one that stays correct. - // A full reload drops the filter, the expanded branches and the click ranking, + // 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(); @@ -285,8 +299,5 @@ export default { - `, - created() { - this.tabs = TABS; - } + ` }; diff --git a/public/js/components/Cms/Form/ContentcomponentDialog.js b/public/js/components/Cms/Form/ContentcomponentDialog.js index e5e38379f..ff5fe7ded 100644 --- a/public/js/components/Cms/Form/ContentcomponentDialog.js +++ b/public/js/components/Cms/Form/ContentcomponentDialog.js @@ -18,7 +18,12 @@ import { const FELD_KATEGORIE = '__kategorie'; const FELD_DOKUMENT = '__dokument'; -const LEERE_AUSWAHL = { value: '', text: '— auswählen —' }; +// 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. @@ -27,7 +32,8 @@ const SOURCES = { request: () => ApiCmsAdmin.getOrganisationseinheiten(), item: oe => ({ value: oe.oe_kurzbz, - text: (oe.bezeichnung || oe.oe_kurzbz) + (oe.aktiv ? '' : ' (inaktiv)') + text: (oe.bezeichnung || oe.oe_kurzbz) + + (oe.aktiv ? '' : ' (' + t('inaktiv') + ')') }) }, dmskategorie: { @@ -100,6 +106,8 @@ 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 = {}; @@ -127,20 +135,23 @@ export function createContentcomponentDialog(options) return; editor.windowManager.open({ - title: 'Contentcomponent einfügen', + title: t('ccDialogTitel'), initialData: { component: catalog[0].name }, body: { type: 'panel', items: [{ type: 'selectbox', name: 'component', - label: 'Contentcomponent', - items: catalog.map(entry => ({ value: entry.name, text: entry.label })) + label: t('ccContentcomponent'), + items: catalog.map(entry => ({ + value: entry.name, + text: t(entry.labelPhrase) + })) }] }, buttons: [ - { type: 'cancel', text: 'Abbrechen' }, - { type: 'submit', text: 'Weiter', primary: true } + { type: 'cancel', text: t('abbrechen') }, + { type: 'submit', text: t('weiter'), primary: true } ], onSubmit(dialog) { const name = dialog.getData().component; @@ -183,7 +194,7 @@ export function createContentcomponentDialog(options) const wert = values && values[propName]; if (wert && !liste.some(eintrag => eintrag.value === wert)) - liste.unshift({ value: wert, text: wert + ' (nicht in der Auswahl)' }); + liste.unshift({ value: wert, text: t('ccNichtInDerAuswahl', [wert]) }); return liste; } @@ -196,7 +207,7 @@ export function createContentcomponentDialog(options) alle.push({ type: 'selectbox', name: propName, - label: spec.label || propName, + label: spec.labelPhrase ? t(spec.labelPhrase) : propName, items: listeFuer(propName, spec) }); return alle; @@ -207,7 +218,7 @@ export function createContentcomponentDialog(options) alle.push({ type: 'checkbox', name: propName, - label: spec.label || propName + label: spec.labelPhrase ? t(spec.labelPhrase) : propName }); return alle; } @@ -215,7 +226,7 @@ export function createContentcomponentDialog(options) alle.push({ type: 'input', name: propName, - label: spec.label || propName + label: spec.labelPhrase ? t(spec.labelPhrase) : propName }); if (spec.picker === 'dms') @@ -223,14 +234,14 @@ export function createContentcomponentDialog(options) alle.push({ type: 'selectbox', name: propName + FELD_KATEGORIE, - label: 'Kategorie', - items: [LEERE_AUSWAHL].concat(sourceItems.dmskategorie || []) + label: t('ccKategorie'), + items: [leereAuswahl()].concat(sourceItems.dmskategorie || []) }); alle.push({ type: 'selectbox', name: propName + FELD_DOKUMENT, - label: 'Dokument hinzufügen', - items: [LEERE_AUSWAHL].concat(dokumente) + label: t('ccDokumentHinzufuegen'), + items: [leereAuswahl()].concat(dokumente) }); } @@ -241,15 +252,15 @@ export function createContentcomponentDialog(options) function konfigBauen(daten) { return { - title: descriptor.label, + title: t(descriptor.labelPhrase), initialData: daten, body: { type: 'panel', items: itemsBauen() }, onChange(dialog, details) { aufAenderung(dialog, details.name); }, buttons: [ - { type: 'cancel', text: 'Abbrechen' }, - { type: 'submit', text: 'Übernehmen', primary: true } + { type: 'cancel', text: t('abbrechen') }, + { type: 'submit', text: t('uebernehmen'), primary: true } ], onSubmit(dialog) { const html = markerHtml(name, descriptor, dialog.getData()); @@ -291,8 +302,9 @@ export function createContentcomponentDialog(options) 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.name - + (dokument.cis_suche ? '' : ' (nicht in der CIS-Suche)') + text: dokument.cis_suche + ? dokument.name + : t('ccNichtInCisSuche', [dokument.name]) })); }) .catch(() => { dokumente = []; }) diff --git a/public/js/components/Cms/Form/FieldWysiwyg.js b/public/js/components/Cms/Form/FieldWysiwyg.js index c2b696256..50b38458b 100644 --- a/public/js/components/Cms/Form/FieldWysiwyg.js +++ b/public/js/components/Cms/Form/FieldWysiwyg.js @@ -65,6 +65,8 @@ export default { // 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()) }); @@ -85,18 +87,24 @@ export default { + 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');' + 'font:12px monospace;color:#555;}', forced_root_block: '', - style_formats: [ - { title: 'Contenttabelle', selector: 'table', classes: 'cmstable' }, - { title: 'Hauptcontent', block: 'div', classes: 'cmscontent' }, - { title: 'Menuebox', block: 'div', classes: 'menubox' }, - { title: 'Teambox', block: 'div', classes: 'teambox' }, - { title: 'Schatteneffekt Grafiken', selector: 'img', classes: 'screenshot_boxshadow' }, - { title: 'Tablesorter', selector: 'table', - classes: 'tablesorter tablesorter_col_0 tablesorter_sort_0' } - ], + // 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 | styleselect fontsizeselect', + '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' ], @@ -110,8 +118,8 @@ export default { vm.editor = editor; editor.ui.registry.addButton('contentcomponent', { - text: 'Contentcomponent', - tooltip: 'Contentcomponent einfügen', + text: vm.$p.t('cms/ccContentcomponent'), + tooltip: vm.$p.t('cms/ccDialogTitel'), onAction: () => vm.contentcomponentDialog.openPicker(editor) }); diff --git a/public/js/components/Cms/Header/ContentHeader.js b/public/js/components/Cms/Header/ContentHeader.js index 03619749c..b6132da7d 100644 --- a/public/js/components/Cms/Header/ContentHeader.js +++ b/public/js/components/Cms/Header/ContentHeader.js @@ -19,7 +19,9 @@ export default { return { titel: '', allSprachen: [], - versionDetails: [] + versionDetails: [], + // Target of the translation dropdown, used once the list grows past a pair. + neueSprache: '' }; }, computed: { @@ -104,6 +106,7 @@ export default { createTranslation(newSprache) { if (!newSprache) return; + this.neueSprache = ''; this.$api .call(ApiCmsAdmin.postTranslation( this.contentId, this.sprache, this.version, newSprache @@ -149,10 +152,27 @@ export default { >{{ $p.t('cms/neueVersionAnlegen') }}
- + + +
diff --git a/public/js/components/Cms/Header/LanguageSelect.js b/public/js/components/Cms/Header/LanguageSelect.js index f4b365ef5..97935da13 100644 --- a/public/js/components/Cms/Header/LanguageSelect.js +++ b/public/js/components/Cms/Header/LanguageSelect.js @@ -5,8 +5,24 @@ export 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/Preview.js b/public/js/components/Cms/Preview.js index 40eee3909..4c0e13299 100644 --- a/public/js/components/Cms/Preview.js +++ b/public/js/components/Cms/Preview.js @@ -1,3 +1,14 @@ +// 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: { @@ -5,8 +16,42 @@ export default { 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 @@ -27,6 +72,40 @@ export default { + 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() { @@ -34,31 +113,195 @@ export default { } }, 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') }}
- -
- Dieser Inhalt enthält Contentcomponents. Die Vorschau unten zeigt sie nicht, - weil sie den alten Renderer ohne Vue verwendet. Prüfe sie über den Link - "In CIS4 ansehen". -
+ diff --git a/public/js/components/Cms/Tabs/Delete.js b/public/js/components/Cms/Tabs/Delete.js index 6a47e2e97..2bbdbb5a7 100644 --- a/public/js/components/Cms/Tabs/Delete.js +++ b/public/js/components/Cms/Tabs/Delete.js @@ -19,7 +19,7 @@ export default { hasRestrict() { return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content'); }, - // Request to T07: forward defaultLanguage prop to tabs. Using 'German' as fallback. + // forward defaultLanguage prop to tabs. Using 'German' as fallback. isLastVersion() { if (!this.contentInfo?.versions || !this.sprache) return false; const defaultLang = 'German'; @@ -125,13 +125,19 @@ export default {