diff --git a/config/assets.php b/config/assets.php index a3a0d10..5d542b6 100644 --- a/config/assets.php +++ b/config/assets.php @@ -46,6 +46,7 @@ return [ 'css/vendor-overrides/editorjs.css', 'css/components/app-page-copy.css', 'css/layout/app-aside-icon-bar.css', + 'css/components/app-bookmark-form.css', ], 'login' => [ 'css/pages/app-login.css', diff --git a/db/init/init.sql b/db/init/init.sql index 1de4c28..9aaf904 100644 --- a/db/init/init.sql +++ b/db/init/init.sql @@ -298,20 +298,35 @@ CREATE TABLE IF NOT EXISTS `user_departments` ( CONSTRAINT `fk_user_departments_department` FOREIGN KEY (`department_id`) REFERENCES `departments` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE IF NOT EXISTS `user_saved_filters` ( +CREATE TABLE IF NOT EXISTS `user_bookmark_groups` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `uuid` CHAR(36) NOT NULL, `user_id` INT UNSIGNED NOT NULL, - `context` VARCHAR(64) NOT NULL, - `name` VARCHAR(120) NOT NULL, - `query_json` TEXT NOT NULL, + `name` VARCHAR(100) NOT NULL, + `icon` VARCHAR(40) NOT NULL DEFAULT 'bi-folder', + `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0, `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uniq_user_saved_filters_uuid` (`uuid`), - KEY `idx_user_saved_filters_user_context` (`user_id`, `context`), - KEY `idx_user_saved_filters_user_context_created` (`user_id`, `context`, `created`), - CONSTRAINT `fk_user_saved_filters_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE + KEY `idx_ubg_user_sort` (`user_id`, `sort_order`), + CONSTRAINT `fk_ubg_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_bookmarks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `group_id` INT UNSIGNED NULL, + `name` VARCHAR(120) NOT NULL, + `url` VARCHAR(500) NOT NULL, + `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_ub_user_url` (`user_id`, `url`), + KEY `idx_ub_user_sort` (`user_id`, `sort_order`), + KEY `idx_ub_user_group_sort` (`user_id`, `group_id`, `sort_order`), + KEY `idx_ub_group` (`group_id`), + CONSTRAINT `fk_ub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ub_group` FOREIGN KEY (`group_id`) REFERENCES `user_bookmark_groups` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `tenant_custom_field_definitions` ( diff --git a/db/updates/2026-03-14-user-bookmarks.sql b/db/updates/2026-03-14-user-bookmarks.sql new file mode 100644 index 0000000..48688b7 --- /dev/null +++ b/db/updates/2026-03-14-user-bookmarks.sql @@ -0,0 +1,108 @@ +-- Replace user_saved_filters with global bookmark system. +-- Hard cut: existing saved filters are removed and not migrated. +-- Hard cut: bookmark-level icons are removed; icons live on groups only. +-- Idempotent: safe to run multiple times. + +DROP TABLE IF EXISTS `user_saved_filters`; + +CREATE TABLE IF NOT EXISTS `user_bookmark_groups` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `name` VARCHAR(100) NOT NULL, + `icon` VARCHAR(40) NOT NULL DEFAULT 'bi-folder', + `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_ubg_user_sort` (`user_id`, `sort_order`), + CONSTRAINT `fk_ubg_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `user_bookmarks` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `group_id` INT UNSIGNED NULL, + `name` VARCHAR(120) NOT NULL, + `url` VARCHAR(500) NOT NULL, + `sort_order` SMALLINT UNSIGNED NOT NULL DEFAULT 0, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_ub_user_url` (`user_id`, `url`), + KEY `idx_ub_user_sort` (`user_id`, `sort_order`), + KEY `idx_ub_group` (`group_id`), + CONSTRAINT `fk_ub_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_ub_group` FOREIGN KEY (`group_id`) REFERENCES `user_bookmark_groups` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SET @ubg_icon_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_bookmark_groups' + AND COLUMN_NAME = 'icon' +); +SET @sql = IF(@ubg_icon_exists = 0, + 'ALTER TABLE `user_bookmark_groups` ADD COLUMN `icon` VARCHAR(40) NOT NULL DEFAULT ''bi-folder'' AFTER `name`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE `user_bookmark_groups` +SET `icon` = 'bi-folder' +WHERE `icon` IS NULL OR TRIM(`icon`) = ''; + +SET @ub_icon_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_bookmarks' + AND COLUMN_NAME = 'icon' +); +SET @sql = IF(@ub_icon_exists > 0, + 'ALTER TABLE `user_bookmarks` DROP COLUMN `icon`', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- Keep the newest row for exact duplicate user+url pairs before adding unique key. +DELETE ub1 +FROM user_bookmarks ub1 +JOIN user_bookmarks ub2 + ON ub1.user_id = ub2.user_id + AND ub1.url = ub2.url + AND ub1.id < ub2.id; + +SET @idx_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_bookmarks' + AND INDEX_NAME = 'uniq_ub_user_url' +); +SET @sql = IF(@idx_exists = 0, + 'ALTER TABLE `user_bookmarks` ADD UNIQUE KEY `uniq_ub_user_url` (`user_id`, `url`)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @idx_group_sort_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_bookmarks' + AND INDEX_NAME = 'idx_ub_user_group_sort' +); +SET @sql = IF(@idx_group_sort_exists = 0, + 'ALTER TABLE `user_bookmarks` ADD KEY `idx_ub_user_group_sort` (`user_id`, `group_id`, `sort_order`)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/docs/howto-fehlerbehebung.md b/docs/howto-fehlerbehebung.md index 7c53325..763dc8e 100644 --- a/docs/howto-fehlerbehebung.md +++ b/docs/howto-fehlerbehebung.md @@ -1,6 +1,6 @@ # Fehlerbehebung -Letzte Aktualisierung: 2026-03-06 +Letzte Aktualisierung: 2026-03-14 ## App reagiert nicht wie erwartet @@ -58,6 +58,12 @@ Neue Felder/Features fehlen lokal. docker compose restart php ``` +### Hinweis (Hard Cut 2026-03-14) + +Das Update `db/updates/2026-03-14-user-bookmarks.sql` entfernt `user_saved_filters` bewusst ohne Migration nach `user_bookmarks`. +Zusätzlich wurde der alte Bookmark-Icon-Ansatz pro Eintrag entfernt: Icons gelten nur noch auf Gruppenebene (`user_bookmark_groups.icon`), bestehende Bookmark-Icons werden nicht übernommen. +Wenn gespeicherte Filter noch benötigt werden, vor dem Update ein DB-Backup erstellen. + ## i18n Fehler ### Symptom diff --git a/i18n/default_de.json b/i18n/default_de.json index d80a711..14cf1eb 100644 --- a/i18n/default_de.json +++ b/i18n/default_de.json @@ -520,6 +520,7 @@ "Department can not be created": "Abteilung kann nicht erstellt werden", "Department can not be updated": "Abteilung kann nicht aktualisiert werden", "Delete this department?": "Diese Abteilung wirklich löschen?", + "Delete this bookmark?": "Dieses Lesezeichen wirklich löschen?", "Delete department": "Abteilung löschen", "This will permanently delete this department.": "Diese Abteilung wird dauerhaft gelöscht.", "Assigned departments": "Zugewiesene Abteilungen", @@ -647,15 +648,6 @@ "SMTP from address": "Absender-Adresse", "SMTP from name": "Absendername", "Address book": "Adressbuch", - "Saved filters": "Gespeicherte Filter", - "Save filter": "Filter speichern", - "Enter a name for this filter": "Gib einen Namen für diesen Filter ein", - "Filter name is required": "Filtername ist erforderlich", - "Maximum number of saved filters reached": "Maximale Anzahl gespeicherter Filter erreicht", - "Filter saved": "Filter gespeichert", - "Delete saved filter": "Gespeicherten Filter löschen", - "Filter deleted": "Filter gelöscht", - "Filter save failed": "Filter konnte nicht gespeichert werden", "Mobile": "Mobil", "Short dial": "Kurzwahl", "setting.smtp_host": "SMTP-Serveradresse (z.B. smtp.example.com)", @@ -1307,5 +1299,45 @@ "Session expiring": "Sitzung läuft ab", "Your session will expire in {countdown}. Would you like to continue?": "Ihre Sitzung läuft in {countdown} ab. Möchten Sie fortfahren?", "Extend session": "Sitzung verlängern", - "Log out": "Abmelden" + "Log out": "Abmelden", + "Bookmarks": "Lesezeichen", + "Bookmark this page": "Seite als Lesezeichen speichern", + "Save bookmark": "Lesezeichen speichern", + "Bookmark name": "Lesezeichen-Name", + "Delete bookmark": "Lesezeichen löschen", + "No bookmarks yet": "Noch keine Lesezeichen", + "New group": "Neue Gruppe", + "No group": "Keine Gruppe", + "Assign to group": "Gruppe zuweisen", + "Group name": "Gruppenname", + "Delete group": "Gruppe löschen", + "Group": "Gruppe", + "Icon": "Symbol", + "Maximum bookmarks reached": "Maximale Anzahl an Lesezeichen erreicht", + "Edit bookmark": "Lesezeichen bearbeiten", + "Save changes": "Änderungen speichern", + "Bookmark saved": "Lesezeichen gespeichert", + "Bookmark updated": "Lesezeichen aktualisiert", + "Bookmark deleted": "Lesezeichen gelöscht", + "Group created": "Gruppe erstellt", + "Group deleted": "Gruppe gelöscht", + "Group delete failed": "Gruppe konnte nicht gelöscht werden", + "Delete group and keep bookmarks?": "Gruppe löschen und enthaltene Lesezeichen behalten?", + "Bookmark action failed": "Lesezeichen-Aktion fehlgeschlagen", + "Group action failed": "Gruppen-Aktion fehlgeschlagen", + "Reorder saved": "Sortierung gespeichert", + "Reorder failed": "Sortierung fehlgeschlagen", + "Move group up": "Gruppe nach oben", + "Move group down": "Gruppe nach unten", + "Move bookmark up": "Lesezeichen nach oben", + "Move bookmark down": "Lesezeichen nach unten", + "Group actions": "Gruppenaktionen", + "Bookmark actions": "Lesezeichenaktionen", + "Bookmark name is required": "Lesezeichen-Name ist erforderlich", + "Bookmark URL is invalid": "Lesezeichen-URL ist ungültig", + "Group not found": "Gruppe nicht gefunden", + "Edit group": "Gruppe bearbeiten", + "Group updated": "Gruppe aktualisiert", + "Group name is required": "Gruppenname ist erforderlich", + "Maximum groups reached": "Maximale Anzahl an Gruppen erreicht" } diff --git a/i18n/default_en.json b/i18n/default_en.json index 8c2922e..54de160 100644 --- a/i18n/default_en.json +++ b/i18n/default_en.json @@ -520,6 +520,7 @@ "Department can not be created": "Department can not be created", "Department can not be updated": "Department can not be updated", "Delete this department?": "Delete this department?", + "Delete this bookmark?": "Delete this bookmark?", "Delete department": "Delete department", "This will permanently delete this department.": "This will permanently delete this department.", "Assigned departments": "Assigned departments", @@ -647,15 +648,6 @@ "SMTP from address": "SMTP from address", "SMTP from name": "SMTP from name", "Address book": "Address book", - "Saved filters": "Saved filters", - "Save filter": "Save filter", - "Enter a name for this filter": "Enter a name for this filter", - "Filter name is required": "Filter name is required", - "Maximum number of saved filters reached": "Maximum number of saved filters reached", - "Filter saved": "Filter saved", - "Delete saved filter": "Delete saved filter", - "Filter deleted": "Filter deleted", - "Filter save failed": "Filter save failed", "Mobile": "Mobile", "Short dial": "Short dial", "setting.smtp_host": "SMTP server host (e.g. smtp.example.com)", @@ -1307,5 +1299,45 @@ "Session expiring": "Session expiring", "Your session will expire in {countdown}. Would you like to continue?": "Your session will expire in {countdown}. Would you like to continue?", "Extend session": "Extend session", - "Log out": "Log out" + "Log out": "Log out", + "Bookmarks": "Bookmarks", + "Bookmark this page": "Bookmark this page", + "Save bookmark": "Save bookmark", + "Bookmark name": "Bookmark name", + "Delete bookmark": "Delete bookmark", + "No bookmarks yet": "No bookmarks yet", + "New group": "New group", + "No group": "No group", + "Assign to group": "Assign to group", + "Group name": "Group name", + "Delete group": "Delete group", + "Group": "Group", + "Icon": "Icon", + "Maximum bookmarks reached": "Maximum bookmarks reached", + "Edit bookmark": "Edit bookmark", + "Save changes": "Save changes", + "Bookmark saved": "Bookmark saved", + "Bookmark updated": "Bookmark updated", + "Bookmark deleted": "Bookmark deleted", + "Group created": "Group created", + "Group deleted": "Group deleted", + "Group delete failed": "Group delete failed", + "Delete group and keep bookmarks?": "Delete group and keep bookmarks?", + "Bookmark action failed": "Bookmark action failed", + "Group action failed": "Group action failed", + "Reorder saved": "Reorder saved", + "Reorder failed": "Reorder failed", + "Move group up": "Move group up", + "Move group down": "Move group down", + "Move bookmark up": "Move bookmark up", + "Move bookmark down": "Move bookmark down", + "Group actions": "Group actions", + "Bookmark actions": "Bookmark actions", + "Bookmark name is required": "Bookmark name is required", + "Bookmark URL is invalid": "Bookmark URL is invalid", + "Group not found": "Group not found", + "Edit group": "Edit group", + "Group updated": "Group updated", + "Group name is required": "Group name is required", + "Maximum groups reached": "Maximum groups reached" } diff --git a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php index 68cc7dd..3d952d4 100644 --- a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php +++ b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php @@ -39,6 +39,7 @@ use MintyPHP\Service\Tenant\TenantRepositoryFactory; use MintyPHP\Service\Tenant\TenantScopeService; use MintyPHP\Service\Tenant\TenantServicesFactory; use MintyPHP\Service\Access\AssignableRoleService; +use MintyPHP\Service\Bookmark\BookmarkServicesFactory; use MintyPHP\Service\User\UserGatewayFactory; use MintyPHP\Service\User\UserRepositoryFactory; use MintyPHP\Service\User\UserServicesFactory; @@ -142,6 +143,7 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar $c->get(SettingServicesFactory::class), $c->get(TenantScopeService::class) )); + $container->set(BookmarkServicesFactory::class, static fn (): BookmarkServicesFactory => new BookmarkServicesFactory()); $container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory( $c->get(UserServicesFactory::class), $c->get(AuditServicesFactory::class), @@ -151,7 +153,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar $c->get(DatabaseSessionRepository::class), $c->get(SessionStoreInterface::class), $c->get(CookieStoreInterface::class), - $c->get(RequestRuntimeInterface::class) + $c->get(RequestRuntimeInterface::class), + $c->get(BookmarkServicesFactory::class) )); } } diff --git a/lib/App/Container/Registrars/UserRegistrar.php b/lib/App/Container/Registrars/UserRegistrar.php index a5e7eba..a67e5b0 100644 --- a/lib/App/Container/Registrars/UserRegistrar.php +++ b/lib/App/Container/Registrars/UserRegistrar.php @@ -23,7 +23,8 @@ use MintyPHP\Service\User\UserLifecycleService; use MintyPHP\Service\User\UserPasswordPolicyService; use MintyPHP\Service\User\UserPasswordService; use MintyPHP\Service\User\UserRepositoryFactory; -use MintyPHP\Service\User\UserSavedFilterService; +use MintyPHP\Service\Bookmark\BookmarkService; +use MintyPHP\Service\Bookmark\BookmarkServicesFactory; use MintyPHP\Service\User\UserServicesFactory; use MintyPHP\Service\User\UserTenantContextService; @@ -45,7 +46,7 @@ final class UserRegistrar implements ContainerRegistrar $c->get(UserAccessTemplateService::class), $c->get(BrandingLogoService::class) )); - $container->set(UserSavedFilterService::class, static fn (AppContainer $c): UserSavedFilterService => $c->get(UserServicesFactory::class)->createUserSavedFilterService()); + $container->set(BookmarkService::class, static fn (AppContainer $c): BookmarkService => $c->get(BookmarkServicesFactory::class)->createBookmarkService()); $container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService()); $container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService()); $container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService()); diff --git a/lib/Repository/User/BookmarkGroupRepository.php b/lib/Repository/User/BookmarkGroupRepository.php new file mode 100644 index 0000000..e0f7f06 --- /dev/null +++ b/lib/Repository/User/BookmarkGroupRepository.php @@ -0,0 +1,209 @@ +unwrapList($rows); + } + + public function nextGroupSortOrder(int $userId): int + { + if ($userId <= 0) { + return 1; + } + + $max = DB::selectValue( + 'select COALESCE(max(sort_order), 0) from user_bookmark_groups where user_id = ?', + (string) $userId + ); + + return max(1, ((int) $max) + 1); + } + + public function countByUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + $count = DB::selectValue( + 'select count(*) from user_bookmark_groups where user_id = ?', + (string) $userId + ); + return $count ? (int) $count : 0; + } + + public function findById(int $id, int $userId): array|false + { + if ($id <= 0 || $userId <= 0) { + return false; + } + $rows = DB::select( + 'select id, user_id, name, icon, sort_order, created, modified from user_bookmark_groups where id = ? and user_id = ?', + (string) $id, + (string) $userId + ); + $list = $this->unwrapList($rows); + return $list[0] ?? false; + } + + public function create(array $data): int|false + { + $userId = (int) ($data['user_id'] ?? 0); + $name = trim((string) ($data['name'] ?? '')); + $icon = trim((string) ($data['icon'] ?? 'bi-folder')); + $sortOrder = (int) ($data['sort_order'] ?? 0); + if ($userId <= 0 || $name === '' || $icon === '') { + return false; + } + return DB::insert( + 'insert into user_bookmark_groups (user_id, name, icon, sort_order, created) values (?,?,?,?,NOW())', + (string) $userId, + $name, + $icon, + (string) $sortOrder + ); + } + + public function update(int $id, int $userId, array $data): bool + { + if ($id <= 0 || $userId <= 0) { + return false; + } + + $sets = []; + $params = []; + + if (array_key_exists('name', $data)) { + $name = trim((string) $data['name']); + if ($name === '') { + return false; + } + $sets[] = 'name = ?'; + $params[] = $name; + } + if (array_key_exists('icon', $data)) { + $icon = trim((string) $data['icon']); + if ($icon === '') { + return false; + } + $sets[] = 'icon = ?'; + $params[] = $icon; + } + + if ($sets === []) { + return false; + } + + $params[] = (string) $id; + $params[] = (string) $userId; + + $affected = DB::update( + 'update user_bookmark_groups set ' . implode(', ', $sets) . ' where id = ? and user_id = ?', + ...$params + ); + return $affected !== false; + } + + public function delete(int $id, int $userId): bool + { + if ($id <= 0 || $userId <= 0) { + return false; + } + $deleted = DB::delete( + 'delete from user_bookmark_groups where id = ? and user_id = ?', + (string) $id, + (string) $userId + ); + return $deleted !== false && (int) $deleted > 0; + } + + public function updateSortOrders(int $userId, array $idOrderPairs): bool + { + if ($userId <= 0 || $idOrderPairs === []) { + return false; + } + + $idList = []; + foreach ($idOrderPairs as $pair) { + $id = (int) $pair['id']; + if ($id <= 0) { + return false; + } + $idList[] = $id; + } + $idList = array_values(array_unique($idList)); + + $existingCount = DB::selectValue( + 'select count(*) from user_bookmark_groups where user_id = ? and id in (???)', + (string) $userId, + array_map('strval', $idList) + ); + + if ((int) $existingCount !== count($idList)) { + return false; + } + + $db = DB::handle(); + try { + $db->begin_transaction(); + + foreach ($idOrderPairs as $pair) { + $id = (int) $pair['id']; + $sortOrder = (int) $pair['sort_order']; + if ($id <= 0 || $sortOrder <= 0) { + $db->rollback(); + return false; + } + + $updated = DB::update( + 'update user_bookmark_groups set sort_order = ? where id = ? and user_id = ?', + (string) $sortOrder, + (string) $id, + (string) $userId + ); + + if ($updated === false) { + $db->rollback(); + return false; + } + } + + $db->commit(); + return true; + } catch (\Throwable) { + try { + $db->rollback(); + } catch (\Throwable) { + // no-op + } + return false; + } + } +} diff --git a/lib/Repository/User/BookmarkGroupRepositoryInterface.php b/lib/Repository/User/BookmarkGroupRepositoryInterface.php new file mode 100644 index 0000000..1f3af4f --- /dev/null +++ b/lib/Repository/User/BookmarkGroupRepositoryInterface.php @@ -0,0 +1,25 @@ +> */ + public function listByUser(int $userId): array; + + public function nextGroupSortOrder(int $userId): int; + + public function countByUser(int $userId): int; + + /** @return array|false */ + public function findById(int $id, int $userId): array|false; + + public function create(array $data): int|false; + + public function update(int $id, int $userId, array $data): bool; + + public function delete(int $id, int $userId): bool; + + /** @param list $idOrderPairs */ + public function updateSortOrders(int $userId, array $idOrderPairs): bool; +} diff --git a/lib/Repository/User/BookmarkNavigationRepository.php b/lib/Repository/User/BookmarkNavigationRepository.php new file mode 100644 index 0000000..c7fe822 --- /dev/null +++ b/lib/Repository/User/BookmarkNavigationRepository.php @@ -0,0 +1,125 @@ + $id, 'sort_order' => $sortOrder]; + $groupIds[] = $id; + continue; + } + + $bookmarkPairs[] = ['id' => $id, 'sort_order' => $sortOrder]; + $bookmarkIds[] = $id; + } + + if ($groupPairs === [] && $bookmarkPairs === []) { + return false; + } + + if ($groupIds !== []) { + $uniqueGroupIds = array_values(array_unique($groupIds)); + $groupCount = DB::selectValue( + 'select count(*) from user_bookmark_groups where user_id = ? and id in (???)', + (string) $userId, + array_map('strval', $uniqueGroupIds) + ); + if ((int) $groupCount !== count($uniqueGroupIds)) { + return false; + } + } + + if ($bookmarkIds !== []) { + $uniqueBookmarkIds = array_values(array_unique($bookmarkIds)); + $bookmarkCount = DB::selectValue( + 'select count(*) from user_bookmarks where user_id = ? and group_id is NULL and id in (???)', + (string) $userId, + array_map('strval', $uniqueBookmarkIds) + ); + if ((int) $bookmarkCount !== count($uniqueBookmarkIds)) { + return false; + } + } + + $db = DB::handle(); + try { + $db->begin_transaction(); + + foreach ($groupPairs as $pair) { + $updated = DB::update( + 'update user_bookmark_groups set sort_order = ? where id = ? and user_id = ?', + (string) $pair['sort_order'], + (string) $pair['id'], + (string) $userId + ); + if ($updated === false) { + $db->rollback(); + return false; + } + } + + foreach ($bookmarkPairs as $pair) { + $updated = DB::update( + 'update user_bookmarks set sort_order = ? where id = ? and user_id = ? and group_id is NULL', + (string) $pair['sort_order'], + (string) $pair['id'], + (string) $userId + ); + if ($updated === false) { + $db->rollback(); + return false; + } + } + + $db->commit(); + return true; + } catch (\Throwable) { + try { + $db->rollback(); + } catch (\Throwable) { + // no-op + } + return false; + } + } +} diff --git a/lib/Repository/User/BookmarkNavigationRepositoryInterface.php b/lib/Repository/User/BookmarkNavigationRepositoryInterface.php new file mode 100644 index 0000000..6c41c8a --- /dev/null +++ b/lib/Repository/User/BookmarkNavigationRepositoryInterface.php @@ -0,0 +1,11 @@ + $rootOrderPairs */ + public function updateRootSortOrders(int $userId, array $rootOrderPairs): bool; +} diff --git a/lib/Repository/User/BookmarkRepository.php b/lib/Repository/User/BookmarkRepository.php new file mode 100644 index 0000000..feab4d0 --- /dev/null +++ b/lib/Repository/User/BookmarkRepository.php @@ -0,0 +1,308 @@ +unwrapList($rows); + } + + public function nextBookmarkSortOrder(int $userId, ?int $groupId): int + { + if ($userId <= 0) { + return 1; + } + + if ($groupId !== null && $groupId > 0) { + $max = DB::selectValue( + 'select COALESCE(max(sort_order), 0) from user_bookmarks where user_id = ? and group_id = ?', + (string) $userId, + (string) $groupId + ); + return max(1, ((int) $max) + 1); + } + + $max = DB::selectValue( + 'select COALESCE(max(sort_order), 0) from user_bookmarks where user_id = ? and group_id is NULL', + (string) $userId + ); + return max(1, ((int) $max) + 1); + } + + public function countByUser(int $userId): int + { + if ($userId <= 0) { + return 0; + } + $count = DB::selectValue( + 'select count(*) from user_bookmarks where user_id = ?', + (string) $userId + ); + return $count ? (int) $count : 0; + } + + public function findById(int $id, int $userId): array|false + { + if ($id <= 0 || $userId <= 0) { + return false; + } + $rows = DB::select( + 'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where id = ? and user_id = ?', + (string) $id, + (string) $userId + ); + $list = $this->unwrapList($rows); + return $list[0] ?? false; + } + + public function findByUserAndUrl(int $userId, string $url): array|false + { + $url = trim($url); + if ($userId <= 0 || $url === '') { + return false; + } + + $rows = DB::select( + 'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? and url = ? order by id desc limit 1', + (string) $userId, + $url + ); + $list = $this->unwrapList($rows); + return $list[0] ?? false; + } + + public function create(array $data): int|false + { + $userId = (int) ($data['user_id'] ?? 0); + $name = trim((string) ($data['name'] ?? '')); + $url = trim((string) ($data['url'] ?? '')); + $groupId = isset($data['group_id']) ? (int) $data['group_id'] : null; + $sortOrder = (int) ($data['sort_order'] ?? 0); + + if ($userId <= 0 || $name === '' || $url === '') { + return false; + } + + $groupIdParam = ($groupId !== null && $groupId > 0) ? (string) $groupId : null; + + return DB::insert( + 'insert into user_bookmarks (user_id, group_id, name, url, sort_order, created) values (?,?,?,?,?,NOW())', + (string) $userId, + $groupIdParam, + $name, + $url, + (string) $sortOrder + ); + } + + public function update(int $id, int $userId, array $data): bool + { + if ($id <= 0 || $userId <= 0) { + return false; + } + + $sets = []; + $params = []; + + if (array_key_exists('name', $data)) { + $name = trim((string) $data['name']); + if ($name === '') { + return false; + } + $sets[] = 'name = ?'; + $params[] = $name; + } + if (array_key_exists('group_id', $data)) { + $sets[] = 'group_id = ?'; + $groupId = $data['group_id']; + $params[] = ($groupId !== null && (int) $groupId > 0) ? (string) (int) $groupId : null; + } + if (array_key_exists('url', $data)) { + $url = trim((string) $data['url']); + if ($url === '') { + return false; + } + $sets[] = 'url = ?'; + $params[] = $url; + } + if (array_key_exists('sort_order', $data)) { + $sortOrder = (int) $data['sort_order']; + if ($sortOrder <= 0) { + return false; + } + $sets[] = 'sort_order = ?'; + $params[] = (string) $sortOrder; + } + + if ($sets === []) { + return false; + } + + $params[] = (string) $id; + $params[] = (string) $userId; + + $affected = DB::update( + 'update user_bookmarks set ' . implode(', ', $sets) . ' where id = ? and user_id = ?', + ...$params + ); + return $affected !== false; + } + + public function delete(int $id, int $userId): bool + { + if ($id <= 0 || $userId <= 0) { + return false; + } + $deleted = DB::delete( + 'delete from user_bookmarks where id = ? and user_id = ?', + (string) $id, + (string) $userId + ); + return $deleted !== false && (int) $deleted > 0; + } + + public function updateSortOrders(int $userId, array $idOrderPairs): bool + { + if ($userId <= 0 || $idOrderPairs === []) { + return false; + } + + $idList = []; + foreach ($idOrderPairs as $pair) { + $id = (int) $pair['id']; + if ($id <= 0) { + return false; + } + $idList[] = $id; + } + $idList = array_values(array_unique($idList)); + + $existingCount = DB::selectValue( + 'select count(*) from user_bookmarks where user_id = ? and id in (???)', + (string) $userId, + array_map('strval', $idList) + ); + + if ((int) $existingCount !== count($idList)) { + return false; + } + + $db = DB::handle(); + try { + $db->begin_transaction(); + + foreach ($idOrderPairs as $pair) { + $id = (int) $pair['id']; + $sortOrder = (int) $pair['sort_order']; + if ($id <= 0 || $sortOrder <= 0) { + $db->rollback(); + return false; + } + + $updated = DB::update( + 'update user_bookmarks set sort_order = ? where id = ? and user_id = ?', + (string) $sortOrder, + (string) $id, + (string) $userId + ); + if ($updated === false) { + $db->rollback(); + return false; + } + } + + $db->commit(); + return true; + } catch (\Throwable) { + try { + $db->rollback(); + } catch (\Throwable) { + // no-op + } + return false; + } + } + + public function clearGroupId(int $groupId, int $userId): bool + { + if ($groupId <= 0 || $userId <= 0) { + return false; + } + + $db = DB::handle(); + try { + $db->begin_transaction(); + + $baseSort = DB::selectValue( + 'select GREATEST( + COALESCE((select max(sort_order) from user_bookmarks where user_id = ? and group_id is NULL), 0), + COALESCE((select max(sort_order) from user_bookmark_groups where user_id = ?), 0) + )', + (string) $userId, + (string) $userId + ); + $nextSort = (int) $baseSort; + + $rows = DB::select( + 'select id from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id', + (string) $userId, + (string) $groupId + ); + $bookmarks = $this->unwrapList($rows); + + foreach ($bookmarks as $bookmark) { + $bookmarkId = (int) ($bookmark['id'] ?? 0); + if ($bookmarkId <= 0) { + continue; + } + $nextSort++; + $updated = DB::update( + 'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?', + (string) $nextSort, + (string) $bookmarkId, + (string) $userId + ); + if ($updated === false) { + $db->rollback(); + return false; + } + } + + $db->commit(); + return true; + } catch (\Throwable) { + try { + $db->rollback(); + } catch (\Throwable) { + // no-op + } + return false; + } + } +} diff --git a/lib/Repository/User/BookmarkRepositoryInterface.php b/lib/Repository/User/BookmarkRepositoryInterface.php new file mode 100644 index 0000000..cfb56f4 --- /dev/null +++ b/lib/Repository/User/BookmarkRepositoryInterface.php @@ -0,0 +1,30 @@ +> */ + public function listByUser(int $userId): array; + + public function nextBookmarkSortOrder(int $userId, ?int $groupId): int; + + public function countByUser(int $userId): int; + + /** @return array|false */ + public function findById(int $id, int $userId): array|false; + + /** @return array|false */ + public function findByUserAndUrl(int $userId, string $url): array|false; + + public function create(array $data): int|false; + + public function update(int $id, int $userId, array $data): bool; + + public function delete(int $id, int $userId): bool; + + /** @param list $idOrderPairs */ + public function updateSortOrders(int $userId, array $idOrderPairs): bool; + + public function clearGroupId(int $groupId, int $userId): bool; +} diff --git a/lib/Repository/User/UserSavedFilterRepository.php b/lib/Repository/User/UserSavedFilterRepository.php deleted file mode 100644 index a4e7373..0000000 --- a/lib/Repository/User/UserSavedFilterRepository.php +++ /dev/null @@ -1,86 +0,0 @@ -unwrapList($rows); - } - - public function countByUserAndContext(int $userId, string $context): int - { - if ($userId <= 0 || $context === '') { - return 0; - } - $count = DB::selectValue( - 'select count(*) from user_saved_filters where user_id = ? and context = ?', - (string) $userId, - $context - ); - return $count ? (int) $count : 0; - } - - public function create(array $data): int|false - { - $userId = (int) ($data['user_id'] ?? 0); - $context = trim((string) ($data['context'] ?? '')); - $name = trim((string) ($data['name'] ?? '')); - $queryJson = (string) ($data['query_json'] ?? ''); - if ($userId <= 0 || $context === '' || $name === '' || $queryJson === '') { - return false; - } - return DB::insert( - 'insert into user_saved_filters (uuid, user_id, context, name, query_json, created) values (?,?,?,?,?,NOW())', - $data['uuid'] ?? RepoQuery::uuidV4(), - (string) $userId, - $context, - $name, - $queryJson - ); - } - - public function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool - { - $uuid = trim($uuid); - $context = trim($context); - if ($uuid === '' || $userId <= 0 || $context === '') { - return false; - } - $deleted = DB::delete( - 'delete from user_saved_filters where uuid = ? and user_id = ? and context = ?', - $uuid, - (string) $userId, - $context - ); - return $deleted !== false && (int) $deleted > 0; - } -} diff --git a/lib/Repository/User/UserSavedFilterRepositoryInterface.php b/lib/Repository/User/UserSavedFilterRepositoryInterface.php deleted file mode 100644 index ec5ef12..0000000 --- a/lib/Repository/User/UserSavedFilterRepositoryInterface.php +++ /dev/null @@ -1,15 +0,0 @@ -authSessionTenantContextService ??= new AuthSessionTenantContextService( $this->userServicesFactory->createUserTenantContextService(), - $this->userServicesFactory->createUserSavedFilterService(), + $this->bookmarkServicesFactory->createBookmarkService(), $this->sessionStore ); } diff --git a/lib/Service/Auth/AuthSessionTenantContextService.php b/lib/Service/Auth/AuthSessionTenantContextService.php index 91c4cd4..9d7cd65 100644 --- a/lib/Service/Auth/AuthSessionTenantContextService.php +++ b/lib/Service/Auth/AuthSessionTenantContextService.php @@ -3,14 +3,14 @@ namespace MintyPHP\Service\Auth; use MintyPHP\Http\SessionStoreInterface; -use MintyPHP\Service\User\UserSavedFilterService; +use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Service\User\UserTenantContextService; class AuthSessionTenantContextService { public function __construct( private readonly UserTenantContextService $userTenantContextService, - private readonly UserSavedFilterService $savedFilterService, + private readonly BookmarkService $bookmarkService, private readonly SessionStoreInterface $sessionStore ) { } @@ -24,7 +24,7 @@ class AuthSessionTenantContextService $availableTenants = $this->userTenantContextService->getAvailableTenants($userId); $this->sessionStore->set('available_tenants', $availableTenants); $this->sessionStore->set('available_departments_by_tenant', $this->userTenantContextService->getAvailableDepartmentsByTenant($userId)); - $this->sessionStore->set('address_book_saved_filters', $this->savedFilterService->listForAddressBook($userId)); + $this->sessionStore->set('user_bookmarks', $this->bookmarkService->listGroupedForUser($userId)); if (!$availableTenants) { $this->sessionStore->set('no_active_tenant', true); diff --git a/lib/Service/Bookmark/BookmarkService.php b/lib/Service/Bookmark/BookmarkService.php new file mode 100644 index 0000000..9903e1c --- /dev/null +++ b/lib/Service/Bookmark/BookmarkService.php @@ -0,0 +1,385 @@ + */ + public static function allowedGroupIcons(): array + { + return self::ALLOWED_GROUP_ICONS; + } + + /** + * @return array{groups: list>, ungrouped: list>} + */ + public function listGroupedForUser(int $userId): array + { + $groups = $this->groupRepository->listByUser($userId); + $bookmarks = $this->bookmarkRepository->listByUser($userId); + + $grouped = []; + foreach ($groups as $group) { + $gid = (int) ($group['id'] ?? 0); + $grouped[$gid] = [ + 'id' => $gid, + 'name' => (string) ($group['name'] ?? ''), + 'icon' => (string) ($group['icon'] ?? 'bi-folder'), + 'sort_order' => (int) ($group['sort_order'] ?? 0), + 'bookmarks' => [], + ]; + } + + $ungrouped = []; + foreach ($bookmarks as $bm) { + $item = [ + 'id' => (int) ($bm['id'] ?? 0), + 'name' => (string) ($bm['name'] ?? ''), + 'url' => BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? '')), + 'group_id' => isset($bm['group_id']) ? (int) $bm['group_id'] : null, + 'sort_order' => (int) ($bm['sort_order'] ?? 0), + ]; + + $gid = $item['group_id']; + if ($gid !== null && $gid > 0 && isset($grouped[$gid])) { + $grouped[$gid]['bookmarks'][] = $item; + } else { + $ungrouped[] = $item; + } + } + + return [ + 'groups' => array_values($grouped), + 'ungrouped' => $ungrouped, + ]; + } + + /** + * @return array{ok: bool, mode?: 'created'|'updated', error?: string, bookmark?: array} + */ + public function saveBookmark(int $userId, string $name, string $url, ?int $groupId): array + { + $name = $this->truncate(trim($name), self::NAME_MAX); + if ($name === '') { + return ['ok' => false, 'error' => 'name_required']; + } + + $url = $this->sanitizeUrl($url); + if ($url === '') { + return ['ok' => false, 'error' => 'url_required']; + } + + $existing = $this->findExistingByCanonicalUrl($userId, $url); + + if ($groupId !== null && $groupId > 0) { + $group = $this->groupRepository->findById($groupId, $userId); + if (!$group) { + return ['ok' => false, 'error' => 'group_not_found']; + } + } else { + $groupId = null; + } + + if ($existing) { + $bookmarkId = (int) ($existing['id'] ?? 0); + if ($bookmarkId <= 0) { + return ['ok' => false, 'error' => 'not_found']; + } + + $existingGroupId = isset($existing['group_id']) && (int) $existing['group_id'] > 0 + ? (int) $existing['group_id'] + : null; + + $update = [ + 'name' => $name, + 'group_id' => $groupId, + ]; + + if ($existingGroupId !== $groupId) { + $update['sort_order'] = $groupId === null + ? $this->navigationRepository->nextRootSortOrder($userId) + : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId); + } + + $existingUrl = trim((string) ($existing['url'] ?? '')); + if ($existingUrl !== $url) { + $update['url'] = $url; + } + + if (!$this->bookmarkRepository->update($bookmarkId, $userId, $update)) { + return ['ok' => false, 'error' => 'update_failed']; + } + + return [ + 'ok' => true, + 'mode' => 'updated', + 'bookmark' => [ + 'id' => $bookmarkId, + 'name' => $name, + 'url' => $url, + 'group_id' => $groupId, + ], + ]; + } + + if ($this->bookmarkRepository->countByUser($userId) >= self::MAX_BOOKMARKS) { + return ['ok' => false, 'error' => 'max_reached']; + } + + $id = $this->bookmarkRepository->create([ + 'user_id' => $userId, + 'group_id' => $groupId, + 'name' => $name, + 'url' => $url, + 'sort_order' => $groupId === null + ? $this->navigationRepository->nextRootSortOrder($userId) + : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId), + ]); + + if ($id === false) { + $raceExisting = $this->bookmarkRepository->findByUserAndUrl($userId, $url); + if ($raceExisting) { + $bookmarkId = (int) ($raceExisting['id'] ?? 0); + $raceGroupId = isset($raceExisting['group_id']) && (int) $raceExisting['group_id'] > 0 + ? (int) $raceExisting['group_id'] + : null; + $raceUpdate = [ + 'name' => $name, + 'group_id' => $groupId, + ]; + if ($raceGroupId !== $groupId) { + $raceUpdate['sort_order'] = $groupId === null + ? $this->navigationRepository->nextRootSortOrder($userId) + : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $groupId); + } + if ($bookmarkId > 0 && $this->bookmarkRepository->update($bookmarkId, $userId, $raceUpdate)) { + return [ + 'ok' => true, + 'mode' => 'updated', + 'bookmark' => [ + 'id' => $bookmarkId, + 'name' => $name, + 'url' => $url, + 'group_id' => $groupId, + ], + ]; + } + } + + return ['ok' => false, 'error' => 'create_failed']; + } + + return [ + 'ok' => true, + 'mode' => 'created', + 'bookmark' => [ + 'id' => $id, + 'name' => $name, + 'url' => $url, + 'group_id' => $groupId, + ], + ]; + } + + /** + * @param array $data + * @return array{ok: bool, error?: string} + */ + public function updateBookmark(int $userId, int $bookmarkId, array $data): array + { + $existing = $this->bookmarkRepository->findById($bookmarkId, $userId); + if (!$existing) { + return ['ok' => false, 'error' => 'not_found']; + } + + $update = []; + + if (array_key_exists('name', $data)) { + $name = $this->truncate(trim((string) $data['name']), self::NAME_MAX); + if ($name === '') { + return ['ok' => false, 'error' => 'name_required']; + } + $update['name'] = $name; + } + + if (array_key_exists('group_id', $data)) { + $groupId = $data['group_id']; + $targetGroupId = null; + if ($groupId !== null && $groupId !== '' && (int) $groupId > 0) { + $group = $this->groupRepository->findById((int) $groupId, $userId); + if (!$group) { + return ['ok' => false, 'error' => 'group_not_found']; + } + $targetGroupId = (int) $groupId; + $update['group_id'] = $targetGroupId; + } else { + $update['group_id'] = null; + } + + $existingGroupId = isset($existing['group_id']) && (int) $existing['group_id'] > 0 + ? (int) $existing['group_id'] + : null; + if ($existingGroupId !== $targetGroupId) { + $update['sort_order'] = $targetGroupId === null + ? $this->navigationRepository->nextRootSortOrder($userId) + : $this->bookmarkRepository->nextBookmarkSortOrder($userId, $targetGroupId); + } + } + + if ($update === []) { + return ['ok' => true]; + } + + if (!$this->bookmarkRepository->update($bookmarkId, $userId, $update)) { + return ['ok' => false, 'error' => 'update_failed']; + } + + return ['ok' => true]; + } + + public function deleteBookmark(int $userId, int $bookmarkId): bool + { + return $this->bookmarkRepository->delete($bookmarkId, $userId); + } + + /** @param list $idOrderPairs */ + public function reorderBookmarks(int $userId, array $idOrderPairs): bool + { + return $this->bookmarkRepository->updateSortOrders($userId, $idOrderPairs); + } + + /** + * @return array{ok: bool, mode?: 'created'|'updated', error?: string, group?: array} + */ + public function saveGroup(int $userId, string $name, string $icon, ?int $groupId = null): array + { + $name = $this->truncate(trim($name), self::GROUP_NAME_MAX); + if ($name === '') { + return ['ok' => false, 'error' => 'name_required']; + } + $icon = $this->validateGroupIcon($icon); + + if ($groupId !== null && $groupId > 0) { + $existing = $this->groupRepository->findById($groupId, $userId); + if (!$existing) { + return ['ok' => false, 'error' => 'not_found']; + } + if (!$this->groupRepository->update($groupId, $userId, ['name' => $name, 'icon' => $icon])) { + return ['ok' => false, 'error' => 'update_failed']; + } + return ['ok' => true, 'mode' => 'updated', 'group' => ['id' => $groupId, 'name' => $name, 'icon' => $icon]]; + } + + if ($this->groupRepository->countByUser($userId) >= self::MAX_GROUPS) { + return ['ok' => false, 'error' => 'max_reached']; + } + + $id = $this->groupRepository->create([ + 'user_id' => $userId, + 'name' => $name, + 'icon' => $icon, + 'sort_order' => $this->navigationRepository->nextRootSortOrder($userId), + ]); + + if ($id === false) { + return ['ok' => false, 'error' => 'create_failed']; + } + + return ['ok' => true, 'mode' => 'created', 'group' => ['id' => $id, 'name' => $name, 'icon' => $icon]]; + } + + public function deleteGroup(int $userId, int $groupId): bool + { + if (!$this->bookmarkRepository->clearGroupId($groupId, $userId)) { + return false; + } + return $this->groupRepository->delete($groupId, $userId); + } + + /** @param list $idOrderPairs */ + public function reorderGroups(int $userId, array $idOrderPairs): bool + { + return $this->groupRepository->updateSortOrders($userId, $idOrderPairs); + } + + /** @param list $rootOrderPairs */ + public function reorderRoot(int $userId, array $rootOrderPairs): bool + { + return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs); + } + + private function sanitizeUrl(string $url): string + { + $url = BookmarkUrlNormalizer::canonicalizeRelative($url); + if ($url === '') { + return ''; + } + + if (mb_strlen($url) > self::URL_MAX) { + $url = mb_substr($url, 0, self::URL_MAX); + } + + return $url; + } + + private function validateGroupIcon(string $icon): string + { + $icon = trim($icon); + if (in_array($icon, self::ALLOWED_GROUP_ICONS, true)) { + return $icon; + } + return 'bi-folder'; + } + + private function truncate(string $value, int $maxLength): string + { + if (mb_strlen($value) > $maxLength) { + return mb_substr($value, 0, $maxLength); + } + return $value; + } + + /** @return array|false */ + private function findExistingByCanonicalUrl(int $userId, string $canonicalUrl): array|false + { + $existing = $this->bookmarkRepository->findByUserAndUrl($userId, $canonicalUrl); + if ($existing) { + return $existing; + } + + $rows = $this->bookmarkRepository->listByUser($userId); + foreach ($rows as $row) { + $rowUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($row['url'] ?? '')); + if ($rowUrl === $canonicalUrl) { + return $row; + } + } + + return false; + } +} diff --git a/lib/Service/Bookmark/BookmarkServicesFactory.php b/lib/Service/Bookmark/BookmarkServicesFactory.php new file mode 100644 index 0000000..44dc19d --- /dev/null +++ b/lib/Service/Bookmark/BookmarkServicesFactory.php @@ -0,0 +1,39 @@ +bookmarkService ??= new BookmarkService( + $this->createBookmarkRepository(), + $this->createBookmarkGroupRepository(), + $this->createBookmarkNavigationRepository() + ); + } + + public function createBookmarkRepository(): BookmarkRepository + { + return $this->bookmarkRepository ??= new BookmarkRepository(); + } + + public function createBookmarkGroupRepository(): BookmarkGroupRepository + { + return $this->groupRepository ??= new BookmarkGroupRepository(); + } + + public function createBookmarkNavigationRepository(): BookmarkNavigationRepository + { + return $this->navigationRepository ??= new BookmarkNavigationRepository(); + } +} diff --git a/lib/Service/User/UserRepositoryFactory.php b/lib/Service/User/UserRepositoryFactory.php index 44f50a1..8c56670 100644 --- a/lib/Service/User/UserRepositoryFactory.php +++ b/lib/Service/User/UserRepositoryFactory.php @@ -12,8 +12,6 @@ use MintyPHP\Repository\User\UserListQueryRepository; use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Repository\User\UserReadRepositoryInterface; -use MintyPHP\Repository\User\UserSavedFilterRepository; -use MintyPHP\Repository\User\UserSavedFilterRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Repository\User\UserWriteRepositoryInterface; @@ -22,7 +20,6 @@ class UserRepositoryFactory private ?UserReadRepository $userReadRepository = null; private ?UserWriteRepository $userWriteRepository = null; private ?UserListQueryRepository $userListQueryRepository = null; - private ?UserSavedFilterRepository $userSavedFilterRepository = null; private ?UserTenantRepository $userTenantRepository = null; private ?UserRoleRepository $userRoleRepository = null; private ?UserDepartmentRepository $userDepartmentRepository = null; @@ -42,11 +39,6 @@ class UserRepositoryFactory return $this->userListQueryRepository ??= new UserListQueryRepository(); } - public function createUserSavedFilterRepository(): UserSavedFilterRepositoryInterface - { - return $this->userSavedFilterRepository ??= new UserSavedFilterRepository(); - } - public function createUserTenantRepository(): UserTenantRepositoryInterface { return $this->userTenantRepository ??= new UserTenantRepository(); diff --git a/lib/Service/User/UserSavedFilterService.php b/lib/Service/User/UserSavedFilterService.php deleted file mode 100644 index fd42891..0000000 --- a/lib/Service/User/UserSavedFilterService.php +++ /dev/null @@ -1,228 +0,0 @@ -listByContext($userId, self::CONTEXT_ADDRESS_BOOK); - } - - public function saveAddressBookFilter(int $userId, string $name, array $rawQuery): array - { - return $this->saveByContext($userId, self::CONTEXT_ADDRESS_BOOK, $name, $rawQuery); - } - - public function deleteAddressBookFilter(int $userId, string $uuid): bool - { - return $this->deleteByContext($userId, self::CONTEXT_ADDRESS_BOOK, $uuid); - } - - public function listByContext(int $userId, string $context): array - { - if ($userId <= 0 || $context === '') { - return []; - } - $rows = $this->userSavedFilterRepository->listByUserAndContext($userId, $context); - $list = []; - foreach ($rows as $row) { - $name = trim((string) ($row['name'] ?? '')); - $uuid = trim((string) ($row['uuid'] ?? '')); - if ($name === '' || $uuid === '') { - continue; - } - $query = $this->normalizeQuery($this->decodeQuery((string) ($row['query_json'] ?? ''))); - $list[] = [ - 'id' => (int) ($row['id'] ?? 0), - 'uuid' => $uuid, - 'name' => $name, - 'query' => $query, - 'created' => (string) ($row['created'] ?? ''), - ]; - } - return $list; - } - - public function saveByContext(int $userId, string $context, string $name, array $rawQuery): array - { - $name = $this->truncate(trim($name), self::NAME_MAX_LENGTH); - if ($userId <= 0 || $context === '') { - return ['ok' => false, 'error' => 'invalid_user_or_context']; - } - if ($name === '') { - return ['ok' => false, 'error' => 'name_required']; - } - $count = $this->userSavedFilterRepository->countByUserAndContext($userId, $context); - if ($count >= self::MAX_PER_USER_CONTEXT) { - return ['ok' => false, 'error' => 'max_reached']; - } - $query = $this->normalizeQuery($rawQuery); - $queryJson = json_encode($query, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if (!is_string($queryJson)) { - $queryJson = '{}'; - } - $id = $this->userSavedFilterRepository->create([ - 'user_id' => $userId, - 'context' => $context, - 'name' => $name, - 'query_json' => $queryJson, - ]); - if (!$id) { - return ['ok' => false, 'error' => 'create_failed']; - } - return ['ok' => true, 'id' => (int) $id, 'name' => $name, 'query' => $query]; - } - - public function deleteByContext(int $userId, string $context, string $uuid): bool - { - if ($userId <= 0 || $context === '' || trim($uuid) === '') { - return false; - } - return $this->userSavedFilterRepository->deleteByUuidForUserAndContext($uuid, $userId, $context); - } - - private function decodeQuery(string $json): array - { - $decoded = json_decode($json, true); - return is_array($decoded) ? $decoded : []; - } - - private function normalizeQuery(array $rawQuery): array - { - $search = $this->truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH); - $tenants = $this->normalizeUuidList($rawQuery['tenants'] ?? []); - $departments = RepoQuery::normalizeIdList($rawQuery['departments'] ?? []); - $roles = RepoQuery::normalizeIdList($rawQuery['roles'] ?? []); - $custom = $this->normalizeCustomFieldQuery($rawQuery); - - $query = []; - if ($search !== '') { - $query['search'] = $search; - } - if ($tenants) { - $query['tenants'] = $tenants; - } - if ($departments) { - $query['departments'] = $departments; - } - if ($roles) { - $query['roles'] = $roles; - } - foreach ($custom as $key => $value) { - $query[$key] = $value; - } - - return $query; - } - - private function normalizeCustomFieldQuery(array $rawQuery): array - { - $normalized = []; - foreach ($rawQuery as $rawKey => $rawValue) { - $key = strtolower(trim((string) $rawKey)); - if ($key === '') { - continue; - } - if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { - $value = trim((string) $rawValue); - if ($value !== '' && preg_match('/^[a-z0-9_-]{1,64}$/i', $value)) { - $normalized[$key] = $value; - } - continue; - } - if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { - $ids = RepoQuery::normalizeIdList($rawValue); - if ($ids) { - $normalized[$key] = $ids; - } - continue; - } - if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { - $value = trim((string) $rawValue); - $dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value); - if ($dt && $dt->format('Y-m-d') === $value) { - $normalized[$key] = $value; - } - } - } - ksort($normalized, SORT_STRING); - return $normalized; - } - - private function normalizeUuidList($value): array - { - $items = $this->normalizeStringList($value); - $list = []; - foreach ($items as $item) { - $item = strtolower($item); - if (preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/', $item)) { - $list[] = $item; - } - } - $list = array_values(array_unique($list)); - sort($list, SORT_STRING); - return $list; - } - - private function normalizeStringList($value): array - { - $raw = is_array($value) ? $value : [$value]; - $flat = []; - $collect = static function ($item) use (&$collect, &$flat): void { - if (is_array($item)) { - foreach ($item as $nested) { - $collect($nested); - } - return; - } - $text = trim((string) $item); - if ($text === '') { - return; - } - if (str_contains($text, ',')) { - foreach (explode(',', $text) as $part) { - $part = trim($part); - if ($part !== '') { - $flat[] = $part; - } - } - return; - } - $flat[] = $text; - }; - foreach ($raw as $item) { - $collect($item); - } - return array_values(array_unique($flat)); - } - - private function truncate(string $value, int $maxLength): string - { - if ($maxLength <= 0 || $value === '') { - return $value; - } - if (function_exists('mb_strlen') && function_exists('mb_substr')) { - if (mb_strlen($value) > $maxLength) { - return mb_substr($value, 0, $maxLength); - } - return $value; - } - if (strlen($value) > $maxLength) { - return substr($value, 0, $maxLength); - } - return $value; - } -} diff --git a/lib/Service/User/UserServicesFactory.php b/lib/Service/User/UserServicesFactory.php index 896a05e..680af26 100644 --- a/lib/Service/User/UserServicesFactory.php +++ b/lib/Service/User/UserServicesFactory.php @@ -8,7 +8,6 @@ use MintyPHP\Repository\Support\DatabaseSessionRepository; use MintyPHP\Repository\Tenant\UserTenantRepositoryInterface; use MintyPHP\Repository\User\UserListQueryRepositoryInterface; use MintyPHP\Repository\User\UserReadRepositoryInterface; -use MintyPHP\Repository\User\UserSavedFilterRepositoryInterface; use MintyPHP\Repository\User\UserWriteRepositoryInterface; use MintyPHP\Service\Access\AssignableRoleService; use MintyPHP\Service\Audit\AuditServicesFactory; @@ -19,7 +18,6 @@ class UserServicesFactory private ?UserPasswordPolicyService $userPasswordPolicyService = null; private ?UserPasswordService $userPasswordService = null; private ?UserAvatarService $userAvatarService = null; - private ?UserSavedFilterService $userSavedFilterService = null; private ?UserAssignmentService $userAssignmentService = null; private ?UserTenantContextService $userTenantContextService = null; private ?UserAccountService $userAccountService = null; @@ -93,13 +91,6 @@ class UserServicesFactory return $this->userAvatarService ??= new UserAvatarService(); } - public function createUserSavedFilterService(): UserSavedFilterService - { - return $this->userSavedFilterService ??= new UserSavedFilterService( - $this->createUserSavedFilterRepository() - ); - } - public function createUserReadRepository(): UserReadRepositoryInterface { return $this->userRepositoryFactory->createUserReadRepository(); @@ -115,11 +106,6 @@ class UserServicesFactory return $this->userRepositoryFactory->createUserListQueryRepository(); } - public function createUserSavedFilterRepository(): UserSavedFilterRepositoryInterface - { - return $this->userRepositoryFactory->createUserSavedFilterRepository(); - } - public function createUserTenantRepository(): UserTenantRepositoryInterface { return $this->userRepositoryFactory->createUserTenantRepository(); diff --git a/lib/Support/BookmarkUrlNormalizer.php b/lib/Support/BookmarkUrlNormalizer.php new file mode 100644 index 0000000..3929590 --- /dev/null +++ b/lib/Support/BookmarkUrlNormalizer.php @@ -0,0 +1,146 @@ + $values) { + sort($values, SORT_STRING); + foreach ($values as $value) { + $normalizedPairs[] = rawurlencode($key) . '=' . rawurlencode((string) $value); + } + } + + return implode('&', $normalizedPairs); + } +} diff --git a/lib/Support/helpers/app.php b/lib/Support/helpers/app.php index 7fb0d7d..ed99f35 100644 --- a/lib/Support/helpers/app.php +++ b/lib/Support/helpers/app.php @@ -628,8 +628,10 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer 'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''), 'activeCustomFilters' => appNormalizeAddressBookCustomFilterQuery($query), 'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null) ? $session['available_departments_by_tenant'] : [], - 'savedFilters' => is_array($session['address_book_saved_filters'] ?? null) ? $session['address_book_saved_filters'] : [], ], + 'bookmarks' => is_array($session['user_bookmarks'] ?? null) + ? $session['user_bookmarks'] + : ['groups' => [], 'ungrouped' => []], 'csrfKey' => $csrfKey, 'csrfToken' => $csrfToken, ]; diff --git a/pages/address-book/index(default).phtml b/pages/address-book/index(default).phtml index e46f2cf..f1e58ac 100644 --- a/pages/address-book/index(default).phtml +++ b/pages/address-book/index(default).phtml @@ -24,19 +24,7 @@ require templatePath('partials/app-breadcrumb.phtml'); ?> - -
-
diff --git a/pages/address-book/saved-filter-delete().php b/pages/address-book/saved-filter-delete().php deleted file mode 100644 index d7b8d72..0000000 --- a/pages/address-book/saved-filter-delete().php +++ /dev/null @@ -1,113 +0,0 @@ -all(); - -Guard::requireLogin(); -Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW); - -$buildAddressBookRedirect = static function (array $query): string { - $params = []; - $search = trim((string) ($query['search'] ?? '')); - $tenants = trim((string) ($query['tenants'] ?? '')); - $departments = trim((string) ($query['departments'] ?? '')); - $roles = trim((string) ($query['roles'] ?? '')); - $collectCustom = static function (array $source): array { - $custom = []; - foreach ($source as $rawKey => $rawValue) { - $key = strtolower(trim((string) $rawKey)); - if ($key === '') { - continue; - } - if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { - $value = trim((string) $rawValue); - if ($value !== '') { - $custom[$key] = $value; - } - continue; - } - if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { - $ids = array_values(array_filter(array_map( - 'intval', - array_map('trim', explode(',', (string) $rawValue)) - ), static fn (int $id): bool => $id > 0)); - $ids = array_values(array_unique($ids)); - if ($ids) { - $custom[$key] = implode(',', $ids); - } - continue; - } - if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { - $value = trim((string) $rawValue); - if ($value !== '') { - $custom[$key] = $value; - } - } - } - ksort($custom, SORT_STRING); - return $custom; - }; - - if ($search !== '') { - $params['search'] = $search; - } - if ($tenants !== '') { - $params['tenants'] = $tenants; - } - if ($departments !== '') { - $params['departments'] = $departments; - } - if ($roles !== '') { - $params['roles'] = $roles; - } - foreach ($collectCustom($query) as $customKey => $customValue) { - $params[$customKey] = $customValue; - } - - if (!$params) { - return 'address-book'; - } - return 'address-book?' . http_build_query($params); -}; - -$redirect = $buildAddressBookRedirect(requestInput()->bodyAll()); -$errorBag = formErrors(); -if ((requestInput()->method()) !== 'POST') { - Router::redirect($redirect); - return; -} - -if (!Session::checkCsrfToken()) { - $errorBag->addGlobal(t('Form expired, please try again')); - flashFormErrors($errorBag, 'address-book', 'address_book_saved_filters'); - Router::redirect($redirect); - return; -} - -$userId = (int) ($session['user']['id'] ?? 0); -if ($userId <= 0) { - Router::redirect('login'); - return; -} -$userSavedFilterService = (app(UserServicesFactory::class))->createUserSavedFilterService(); - -$uuid = trim((string) (requestInput()->bodyAll()['uuid'] ?? '')); -if ($uuid === '') { - Router::redirect($redirect); - return; -} - -$deleted = $userSavedFilterService->deleteAddressBookFilter($userId, $uuid); -if ($deleted) { - $sessionStore->set('address_book_saved_filters', $userSavedFilterService->listForAddressBook($userId)); - Flash::success(t('Filter deleted'), 'address-book', 'address_book_saved_filter_deleted'); -} - -Router::redirect($redirect); diff --git a/pages/address-book/saved-filter-save().php b/pages/address-book/saved-filter-save().php deleted file mode 100644 index 9f23e98..0000000 --- a/pages/address-book/saved-filter-save().php +++ /dev/null @@ -1,120 +0,0 @@ -all(); - -Guard::requireLogin(); -Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW); - -$buildAddressBookRedirect = static function (array $query): string { - $params = []; - $search = trim((string) ($query['search'] ?? '')); - $tenants = trim((string) ($query['tenants'] ?? '')); - $departments = trim((string) ($query['departments'] ?? '')); - $roles = trim((string) ($query['roles'] ?? '')); - $collectCustom = static function (array $source): array { - $custom = []; - foreach ($source as $rawKey => $rawValue) { - $key = strtolower(trim((string) $rawKey)); - if ($key === '') { - continue; - } - if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) { - $value = trim((string) $rawValue); - if ($value !== '') { - $custom[$key] = $value; - } - continue; - } - if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) { - $ids = array_values(array_filter(array_map( - 'intval', - array_map('trim', explode(',', (string) $rawValue)) - ), static fn (int $id): bool => $id > 0)); - $ids = array_values(array_unique($ids)); - if ($ids) { - $custom[$key] = implode(',', $ids); - } - continue; - } - if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) { - $value = trim((string) $rawValue); - if ($value !== '') { - $custom[$key] = $value; - } - } - } - ksort($custom, SORT_STRING); - return $custom; - }; - - if ($search !== '') { - $params['search'] = $search; - } - if ($tenants !== '') { - $params['tenants'] = $tenants; - } - if ($departments !== '') { - $params['departments'] = $departments; - } - if ($roles !== '') { - $params['roles'] = $roles; - } - foreach ($collectCustom($query) as $customKey => $customValue) { - $params[$customKey] = $customValue; - } - - if (!$params) { - return 'address-book'; - } - return 'address-book?' . http_build_query($params); -}; - -$redirect = $buildAddressBookRedirect(requestInput()->bodyAll()); -$errorBag = formErrors(); -if ((requestInput()->method()) !== 'POST') { - Router::redirect($redirect); - return; -} - -if (!Session::checkCsrfToken()) { - $errorBag->addGlobal(t('Form expired, please try again')); - flashFormErrors($errorBag, 'address-book', 'address_book_saved_filters'); - Router::redirect($redirect); - return; -} - -$userId = (int) ($session['user']['id'] ?? 0); -if ($userId <= 0) { - Router::redirect('login'); - return; -} -$userSavedFilterService = (app(UserServicesFactory::class))->createUserSavedFilterService(); - -$name = trim((string) (requestInput()->bodyAll()['name'] ?? '')); -$result = $userSavedFilterService->saveAddressBookFilter($userId, $name, requestInput()->bodyAll()); - -if (!($result['ok'] ?? false)) { - $error = (string) ($result['error'] ?? ''); - if ($error === 'name_required') { - $errorBag->addGlobal(t('Filter name is required')); - } elseif ($error === 'max_reached') { - $errorBag->addGlobal(t('Maximum number of saved filters reached')); - } else { - $errorBag->addGlobal(t('Filter save failed')); - } - flashFormErrors($errorBag, 'address-book', 'address_book_saved_filter_save'); - Router::redirect($redirect); - return; -} - -$sessionStore->set('address_book_saved_filters', $userSavedFilterService->listForAddressBook($userId)); -Flash::success(t('Filter saved'), 'address-book', 'address_book_saved_filter_saved'); -Router::redirect($redirect); diff --git a/pages/bookmarks/delete-data().php b/pages/bookmarks/delete-data().php new file mode 100644 index 0000000..10f43d0 --- /dev/null +++ b/pages/bookmarks/delete-data().php @@ -0,0 +1,34 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$bookmarkId = (int) requestInput()->body('id'); + +$service = app(BookmarkService::class); +$deleted = $service->deleteBookmark($userId, $bookmarkId); + +if ($deleted) { + app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); +} + +Router::json(['ok' => $deleted]); diff --git a/pages/bookmarks/group-delete-data().php b/pages/bookmarks/group-delete-data().php new file mode 100644 index 0000000..ffec619 --- /dev/null +++ b/pages/bookmarks/group-delete-data().php @@ -0,0 +1,42 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$groupId = (int) requestInput()->body('id'); +if ($groupId <= 0) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_group_id']); + return; +} + +$service = app(BookmarkService::class); +$deleted = $service->deleteGroup($userId, $groupId); + +if ($deleted) { + app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + Router::json(['ok' => true]); + return; +} + +http_response_code(404); +Router::json(['ok' => false, 'error' => 'not_found']); diff --git a/pages/bookmarks/group-save-data().php b/pages/bookmarks/group-save-data().php new file mode 100644 index 0000000..bcfc740 --- /dev/null +++ b/pages/bookmarks/group-save-data().php @@ -0,0 +1,37 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$name = trim((string) requestInput()->body('name')); +$icon = trim((string) requestInput()->body('icon')); +$groupId = requestInput()->body('id'); +$groupId = ($groupId !== null && $groupId !== '') ? (int) $groupId : null; + +$service = app(BookmarkService::class); +$result = $service->saveGroup($userId, $name, $icon, $groupId); + +if ($result['ok']) { + app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); +} + +Router::json($result); diff --git a/pages/bookmarks/reorder-data().php b/pages/bookmarks/reorder-data().php new file mode 100644 index 0000000..09c3488 --- /dev/null +++ b/pages/bookmarks/reorder-data().php @@ -0,0 +1,122 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$type = trim((string) requestInput()->body('type')); +$allowedTypes = ['groups', 'bookmarks', 'root']; +if (!in_array($type, $allowedTypes, true)) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_type']); + return; +} + +$rawItems = requestInput()->body('items'); +$items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []); + +if (!is_array($items)) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; +} + +$service = app(BookmarkService::class); + +if ($type === 'root') { + $rootPairs = []; + $seenRootIds = []; + $seenSortOrders = []; + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $kind = trim((string) ($item['kind'] ?? '')); + $id = (int) ($item['id'] ?? 0); + $sortOrder = (int) ($item['sort_order'] ?? 0); + if (($kind !== 'group' && $kind !== 'bookmark') || $id <= 0 || $sortOrder <= 0) { + continue; + } + + $dedupeKey = $kind . ':' . $id; + if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; + } + $seenRootIds[$dedupeKey] = true; + $seenSortOrders[$sortOrder] = true; + $rootPairs[] = [ + 'kind' => $kind, + 'id' => $id, + 'sort_order' => $sortOrder, + ]; + } + + $pairCount = count($rootPairs); + if ($pairCount === 0 || $pairCount !== count($items)) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; + } + + $ok = $service->reorderRoot($userId, $rootPairs); +} else { + $pairs = []; + $seenIds = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $id = (int) ($item['id'] ?? 0); + $sortOrder = (int) ($item['sort_order'] ?? 0); + if ($id > 0 && $sortOrder > 0) { + if (isset($seenIds[$id])) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; + } + $seenIds[$id] = true; + $pairs[] = ['id' => $id, 'sort_order' => $sortOrder]; + } + } + + $pairCount = count($pairs); + if ($pairCount === 0 || $pairCount !== count($items)) { + http_response_code(400); + Router::json(['ok' => false, 'error' => 'invalid_items']); + return; + } + + $ok = $type === 'groups' + ? $service->reorderGroups($userId, $pairs) + : $service->reorderBookmarks($userId, $pairs); +} + +if ($ok) { + app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); + Router::json(['ok' => true]); + return; +} + +http_response_code(400); +Router::json(['ok' => false, 'error' => 'reorder_failed']); diff --git a/pages/bookmarks/save-data().php b/pages/bookmarks/save-data().php new file mode 100644 index 0000000..0eb11bb --- /dev/null +++ b/pages/bookmarks/save-data().php @@ -0,0 +1,37 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$name = trim((string) requestInput()->body('name')); +$url = trim((string) requestInput()->body('url')); +$groupId = requestInput()->body('group_id'); +$groupId = ($groupId !== null && $groupId !== '') ? (int) $groupId : null; + +$service = app(BookmarkService::class); +$result = $service->saveBookmark($userId, $name, $url, $groupId); + +if ($result['ok']) { + app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); +} + +Router::json($result); diff --git a/pages/bookmarks/update-data().php b/pages/bookmarks/update-data().php new file mode 100644 index 0000000..ffb8fd3 --- /dev/null +++ b/pages/bookmarks/update-data().php @@ -0,0 +1,42 @@ +method() !== 'POST') { + http_response_code(405); + Router::json(['ok' => false, 'error' => 'method_not_allowed']); + return; +} + +if (!Session::checkCsrfToken()) { + http_response_code(403); + Router::json(['ok' => false, 'error' => 'csrf']); + return; +} + +$session = app(SessionStoreInterface::class)->all(); +$userId = (int) ($session['user']['id'] ?? 0); +$bookmarkId = (int) requestInput()->body('id'); + +$data = []; +if (requestInput()->body('name') !== null) { + $data['name'] = (string) requestInput()->body('name'); +} +if (requestInput()->body('group_id') !== null) { + $data['group_id'] = requestInput()->body('group_id'); +} + +$service = app(BookmarkService::class); +$result = $service->updateBookmark($userId, $bookmarkId, $data); + +if ($result['ok']) { + app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); +} + +Router::json($result); diff --git a/templates/default.phtml b/templates/default.phtml index 3f49df5..dafb17e 100644 --- a/templates/default.phtml +++ b/templates/default.phtml @@ -86,7 +86,9 @@ if ($bufferTitle !== '') { + + diff --git a/templates/partials/app-bookmark-dialog.phtml b/templates/partials/app-bookmark-dialog.phtml new file mode 100644 index 0000000..604f409 --- /dev/null +++ b/templates/partials/app-bookmark-dialog.phtml @@ -0,0 +1,94 @@ + + +
+
+

+ +
+
+ + + + + +
+ +
+
+
+
+ + +
+
+

+ +
+
+ + + +
+ + + +
+ +
+ +
+
+
+
diff --git a/templates/partials/app-main-aside-icon-bar.phtml b/templates/partials/app-main-aside-icon-bar.phtml index e7a96ce..a639397 100644 --- a/templates/partials/app-main-aside-icon-bar.phtml +++ b/templates/partials/app-main-aside-icon-bar.phtml @@ -35,10 +35,11 @@ $addressBookUrl = lurl('address-book');
  • -
  • diff --git a/templates/partials/app-main-aside.phtml b/templates/partials/app-main-aside.phtml index 1f70b0c..053f3d9 100644 --- a/templates/partials/app-main-aside.phtml +++ b/templates/partials/app-main-aside.phtml @@ -1,6 +1,7 @@ @@ -315,87 +315,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? ''); data-aside-details-storage="aside-people-tenant" data-aside-title="" role="tabpanel" aria-labelledby="aside-tab-people" hidden>
      - -
    • - -
        - - $customValue) { - if (is_array($customValue)) { - $savedQueryParams[$customKey] = implode(',', $customValue); - } else { - $savedQueryParams[$customKey] = (string) $customValue; - } - } - - $savedHref = $addressBookUrl; - if ($savedQueryParams) { - $savedHref .= '?' . http_build_query($savedQueryParams); - } - - $isSavedFilterActive = $addressBookActive['isActive'] - && $savedSearch === $activeAddressSearch - && $savedTenants === $activeAddressTenants - && $savedDepartments === $activeAddressDepartments - && $savedRoles === $activeAddressRoles - && $savedCustomFilters === $activeAddressCustomFilters; - ?> -
      • - > - - -
        - - - - - - - $customValue): ?> - - - -
        -
      • - -
      -
    • -
    • -
    • +
    • + +