feat: extract bookmarks as standalone module with MintyPHP\Module\Bookmarks namespace

Moves bookmarks from core hardcoding into modules/bookmarks/ as a
fully self-contained module, following the same pattern as addressbook.

Module contributions via platform slots:
- aside.tab_panel: bookmark sidebar panel
- topbar.right_item: bookmark toggle button
- layout.body_end_template: bookmark/group dialogs
- layout.head_style: bookmark CSS (form + sidebar)
- runtime.component: bookmark-save and bookmark-panel (phase: late)
- search.resource_item: bookmarks in global search (user-scoped via {{userId}})

Backend fully in module namespace (MintyPHP\Module\Bookmarks\*):
- Service: BookmarkService, BookmarkServicesFactory, BookmarkRepositoryFactory
- Repository: BookmarkRepository, BookmarkGroupRepository, BookmarkNavigationRepository
- Support: BookmarkUrlNormalizer
- Providers: BookmarksSessionProvider, BookmarksLayoutProvider, BookmarksSearchProvider

Core cleanup:
- Removed all bookmark-specific markup from core templates
- Removed core DI registrations for bookmark services
- Removed core bookmark pages, JS, CSS
- AuthSessionTenantContextService delegates to module SessionProvider

Architecture guards:
- NoBookmarksHardcodingTest: verifies zero bookmark references in core
- testModuleClassesUseModuleNamespace: prevents core namespace leakage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:20:20 +01:00
parent c7b8fd516a
commit 4871c6032e
37 changed files with 1514 additions and 294 deletions

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Module\Bookmarks;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
final class BookmarksAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_USE = 'bookmarks.use';
public function supports(string $ability): bool
{
return $ability === self::ABILITY_USE;
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace MintyPHP\Module\Bookmarks;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Module\Bookmarks\Service\BookmarkRepositoryFactory;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Module\Bookmarks\Service\BookmarkServicesFactory;
final class BookmarksContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->set(BookmarkRepositoryFactory::class, static fn (): BookmarkRepositoryFactory => new BookmarkRepositoryFactory());
$container->set(BookmarkServicesFactory::class, static fn (AppContainer $c): BookmarkServicesFactory => new BookmarkServicesFactory(
$c->get(BookmarkRepositoryFactory::class)
));
$container->set(BookmarkService::class, static fn (AppContainer $c): BookmarkService => $c->get(BookmarkServicesFactory::class)->createBookmarkService());
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace MintyPHP\Module\Bookmarks\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
final class BookmarksLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
$grouped = is_array($session['module.bookmarks.grouped'] ?? null)
? $session['module.bookmarks.grouped']
: ['groups' => [], 'ungrouped' => []];
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri(
(string) ($_SERVER['REQUEST_URI'] ?? ''),
localeBase()
);
return [
'bookmarks.nav' => [
'grouped' => $grouped,
'current_path' => $currentPath,
],
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace MintyPHP\Module\Bookmarks\Providers;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
/**
* Provides the bookmarks search resource for the global search.
*
* Bookmarks are user-scoped: the SQL uses {{userId}} so only
* the current user's bookmarks are searched.
*/
final class BookmarksSearchProvider implements SearchResourceProvider
{
public function resources(): array
{
return [
'bookmarks' => [
'label' => t('Bookmarks'),
'permission' => '',
'count_sql' => "SELECT COUNT(*) FROM user_bookmarks WHERE user_id = {{userId}} AND (name LIKE ? ESCAPE '\\\\' OR url LIKE ? ESCAPE '\\\\')",
'preview_sql' => "SELECT id, name, url FROM user_bookmarks WHERE user_id = {{userId}} AND (name LIKE ? ESCAPE '\\\\' OR url LIKE ? ESCAPE '\\\\') ORDER BY sort_order ASC LIMIT ?",
'result_sql' => "SELECT id, name, url FROM user_bookmarks WHERE user_id = {{userId}} AND (name LIKE ? ESCAPE '\\\\' OR url LIKE ? ESCAPE '\\\\') ORDER BY sort_order ASC",
],
];
}
public function mapResultItem(string $resourceKey, array $row): ?array
{
if ($resourceKey !== 'bookmarks') {
return null;
}
$name = trim((string) ($row['name'] ?? ''));
$url = trim((string) ($row['url'] ?? ''));
if ($name === '') {
return null;
}
return [
'title' => $name,
'subtitle' => $url,
'url' => $url !== '' ? $url : '#',
'icon' => 'bi-bookmark-star',
];
}
public function listUrl(string $resourceKey, string $encodedSearch): string
{
return '';
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace MintyPHP\Module\Bookmarks\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
final class BookmarksSessionProvider implements SessionProvider
{
private const SESSION_KEY = 'module.bookmarks.grouped';
public function populate(array $user, AppContainer $container): void
{
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) {
unset($_SESSION[self::SESSION_KEY]);
return;
}
$sessionStore = $container->get(SessionStoreInterface::class);
$bookmarkService = $container->get(BookmarkService::class);
if (!$sessionStore instanceof SessionStoreInterface || !$bookmarkService instanceof BookmarkService) {
return;
}
$sessionStore->set(self::SESSION_KEY, $bookmarkService->listGroupedForUser($userId));
}
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY]);
}
}

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace MintyPHP\Repository\User; namespace MintyPHP\Module\Bookmarks\Repository;
use MintyPHP\DB; use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery; use MintyPHP\Repository\Support\RepoQuery;

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace MintyPHP\Repository\User; namespace MintyPHP\Module\Bookmarks\Repository;
interface BookmarkGroupRepositoryInterface interface BookmarkGroupRepositoryInterface
{ {

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace MintyPHP\Repository\User; namespace MintyPHP\Module\Bookmarks\Repository;
use MintyPHP\DB; use MintyPHP\DB;
@@ -36,9 +36,9 @@ class BookmarkNavigationRepository implements BookmarkNavigationRepositoryInterf
$bookmarkIds = []; $bookmarkIds = [];
foreach ($rootOrderPairs as $pair) { foreach ($rootOrderPairs as $pair) {
$kind = trim((string) ($pair['kind'] ?? '')); $kind = trim((string) $pair['kind']);
$id = (int) ($pair['id'] ?? 0); $id = (int) $pair['id'];
$sortOrder = (int) ($pair['sort_order'] ?? 0); $sortOrder = (int) $pair['sort_order'];
if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) { if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) {
return false; return false;
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace MintyPHP\Repository\User; namespace MintyPHP\Module\Bookmarks\Repository;
interface BookmarkNavigationRepositoryInterface interface BookmarkNavigationRepositoryInterface
{ {

View File

@@ -1,34 +1,29 @@
<?php <?php
namespace MintyPHP\Repository\User; namespace MintyPHP\Module\Bookmarks\Repository;
use MintyPHP\DB; use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
class BookmarkRepository implements BookmarkRepositoryInterface class BookmarkRepository implements BookmarkRepositoryInterface
{ {
private function unwrapList(mixed $rows): array private function unwrapList(mixed $rows): array
{ {
if (!is_array($rows)) { return RepositoryArrayHelper::unwrapList($rows, 'user_bookmarks');
return [];
}
$list = [];
foreach ($rows as $row) {
$data = $row['user_bookmarks'] ?? $row;
if (is_array($data)) {
$list[] = $data;
}
}
return $list;
} }
public function listByUser(int $userId): array public function listByUser(int $userId, array $options = []): array
{ {
if ($userId <= 0) { if ($userId <= 0) {
return []; return [];
} }
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options, 100, 1, 100);
$rows = DB::select( $rows = DB::select(
'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? order by sort_order, id', 'select id, user_id, group_id, name, url, sort_order, created, modified from user_bookmarks where user_id = ? order by sort_order, id limit ? offset ?',
(string) $userId (string) $userId,
(string) $limit,
(string) $offset
); );
return $this->unwrapList($rows); return $this->unwrapList($rows);
} }
@@ -249,60 +244,31 @@ class BookmarkRepository implements BookmarkRepositoryInterface
} }
} }
public function clearGroupId(int $groupId, int $userId): bool /** @return list<array<string, mixed>> */
public function listByGroup(int $userId, int $groupId): array
{ {
if ($groupId <= 0 || $userId <= 0) { if ($userId <= 0 || $groupId <= 0) {
return false; return [];
} }
$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( $rows = DB::select(
'select id from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id', 'select id, user_id, group_id, name, url, sort_order from user_bookmarks where user_id = ? and group_id = ? order by sort_order, id',
(string) $userId, (string) $userId,
(string) $groupId (string) $groupId
); );
$bookmarks = $this->unwrapList($rows); return $this->unwrapList($rows);
}
foreach ($bookmarks as $bookmark) {
$bookmarkId = (int) ($bookmark['id'] ?? 0); public function unsetGroupId(int $bookmarkId, int $userId, int $sortOrder): bool
if ($bookmarkId <= 0) { {
continue; if ($bookmarkId <= 0 || $userId <= 0 || $sortOrder <= 0) {
return false;
} }
$nextSort++;
$updated = DB::update( $updated = DB::update(
'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?', 'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?',
(string) $nextSort, (string) $sortOrder,
(string) $bookmarkId, (string) $bookmarkId,
(string) $userId (string) $userId
); );
if ($updated === false) { return $updated !== false;
$db->rollback();
return false;
}
}
$db->commit();
return true;
} catch (\Throwable) {
try {
$db->rollback();
} catch (\Throwable) {
// no-op
}
return false;
}
} }
} }

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace MintyPHP\Repository\User; namespace MintyPHP\Module\Bookmarks\Repository;
interface BookmarkRepositoryInterface interface BookmarkRepositoryInterface
{ {

View File

@@ -1,38 +1,31 @@
<?php <?php
namespace MintyPHP\Service\Bookmark; namespace MintyPHP\Module\Bookmarks\Service;
use MintyPHP\Repository\User\BookmarkGroupRepository; use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepository;
use MintyPHP\Repository\User\BookmarkNavigationRepository; use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
use MintyPHP\Repository\User\BookmarkRepository; use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepository;
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepository;
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
class BookmarkServicesFactory class BookmarkRepositoryFactory
{ {
private ?BookmarkService $bookmarkService = null;
private ?BookmarkRepository $bookmarkRepository = null; private ?BookmarkRepository $bookmarkRepository = null;
private ?BookmarkGroupRepository $groupRepository = null; private ?BookmarkGroupRepository $groupRepository = null;
private ?BookmarkNavigationRepository $navigationRepository = null; private ?BookmarkNavigationRepository $navigationRepository = null;
public function createBookmarkService(): BookmarkService public function createBookmarkRepository(): BookmarkRepositoryInterface
{
return $this->bookmarkService ??= new BookmarkService(
$this->createBookmarkRepository(),
$this->createBookmarkGroupRepository(),
$this->createBookmarkNavigationRepository()
);
}
public function createBookmarkRepository(): BookmarkRepository
{ {
return $this->bookmarkRepository ??= new BookmarkRepository(); return $this->bookmarkRepository ??= new BookmarkRepository();
} }
public function createBookmarkGroupRepository(): BookmarkGroupRepository public function createBookmarkGroupRepository(): BookmarkGroupRepositoryInterface
{ {
return $this->groupRepository ??= new BookmarkGroupRepository(); return $this->groupRepository ??= new BookmarkGroupRepository();
} }
public function createBookmarkNavigationRepository(): BookmarkNavigationRepository public function createBookmarkNavigationRepository(): BookmarkNavigationRepositoryInterface
{ {
return $this->navigationRepository ??= new BookmarkNavigationRepository(); return $this->navigationRepository ??= new BookmarkNavigationRepository();
} }

View File

@@ -1,11 +1,11 @@
<?php <?php
namespace MintyPHP\Service\Bookmark; namespace MintyPHP\Module\Bookmarks\Service;
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface; use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface; use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
use MintyPHP\Repository\User\BookmarkRepositoryInterface; use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
use MintyPHP\Support\BookmarkUrlNormalizer; use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
class BookmarkService class BookmarkService
{ {
@@ -94,7 +94,7 @@ class BookmarkService
return ['ok' => false, 'error' => 'url_required']; return ['ok' => false, 'error' => 'url_required'];
} }
$existing = $this->findExistingByCanonicalUrl($userId, $url); $existing = $this->bookmarkRepository->findByUserAndUrl($userId, $url);
if ($groupId !== null && $groupId > 0) { if ($groupId !== null && $groupId > 0) {
$group = $this->groupRepository->findById($groupId, $userId); $group = $this->groupRepository->findById($groupId, $userId);
@@ -315,9 +315,20 @@ class BookmarkService
public function deleteGroup(int $userId, int $groupId): bool public function deleteGroup(int $userId, int $groupId): bool
{ {
if (!$this->bookmarkRepository->clearGroupId($groupId, $userId)) { $bookmarks = $this->bookmarkRepository->listByGroup($userId, $groupId);
$nextSort = $this->navigationRepository->nextRootSortOrder($userId);
foreach ($bookmarks as $bookmark) {
$bookmarkId = (int) ($bookmark['id'] ?? 0);
if ($bookmarkId <= 0) {
continue;
}
if (!$this->bookmarkRepository->unsetGroupId($bookmarkId, $userId, $nextSort)) {
return false; return false;
} }
$nextSort++;
}
return $this->groupRepository->delete($groupId, $userId); return $this->groupRepository->delete($groupId, $userId);
} }
@@ -333,6 +344,42 @@ class BookmarkService
return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs); return $this->navigationRepository->updateRootSortOrders($userId, $rootOrderPairs);
} }
/**
* @param list<array{id: int, sort_order: int}> $items
* @return array{ok: bool, error?: string}
*/
public function reorderFromItems(int $userId, string $type, array $items): array
{
$allowedTypes = ['groups', 'bookmarks'];
if (!in_array($type, $allowedTypes, true)) {
return ['ok' => false, 'error' => 'invalid_type'];
}
if ($items === []) {
return ['ok' => false, 'error' => 'invalid_items'];
}
$ok = $type === 'groups'
? $this->reorderGroups($userId, $items)
: $this->reorderBookmarks($userId, $items);
return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'reorder_failed'];
}
/**
* @param list<array{kind: 'group'|'bookmark', id: int, sort_order: int}> $rootPairs
* @return array{ok: bool, error?: string}
*/
public function reorderRootFromItems(int $userId, array $rootPairs): array
{
if ($rootPairs === []) {
return ['ok' => false, 'error' => 'invalid_items'];
}
$ok = $this->reorderRoot($userId, $rootPairs);
return $ok ? ['ok' => true] : ['ok' => false, 'error' => 'reorder_failed'];
}
private function sanitizeUrl(string $url): string private function sanitizeUrl(string $url): string
{ {
$url = BookmarkUrlNormalizer::canonicalizeRelative($url); $url = BookmarkUrlNormalizer::canonicalizeRelative($url);
@@ -364,22 +411,4 @@ class BookmarkService
return $value; return $value;
} }
/** @return array<string, mixed>|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;
}
} }

View File

@@ -0,0 +1,22 @@
<?php
namespace MintyPHP\Module\Bookmarks\Service;
class BookmarkServicesFactory
{
private ?BookmarkService $bookmarkService = null;
public function __construct(
private readonly BookmarkRepositoryFactory $bookmarkRepositoryFactory
) {
}
public function createBookmarkService(): BookmarkService
{
return $this->bookmarkService ??= new BookmarkService(
$this->bookmarkRepositoryFactory->createBookmarkRepository(),
$this->bookmarkRepositoryFactory->createBookmarkGroupRepository(),
$this->bookmarkRepositoryFactory->createBookmarkNavigationRepository()
);
}
}

View File

@@ -1,6 +1,6 @@
<?php <?php
namespace MintyPHP\Support; namespace MintyPHP\Module\Bookmarks\Support;
final class BookmarkUrlNormalizer final class BookmarkUrlNormalizer
{ {

View File

@@ -0,0 +1,136 @@
<?php
/**
* Bookmarks module manifest.
*
* Owns bookmark routes, topbar/aside/dialog UI, runtime components,
* and bookmark session/layout providers.
*/
return [
'id' => 'bookmarks',
'version' => '1.0.0',
'enabled_by_default' => true,
'load_order' => 20,
'routes' => [
['path' => 'bookmarks/save-data', 'target' => 'bookmarks/save-data'],
['path' => 'bookmarks/update-data', 'target' => 'bookmarks/update-data'],
['path' => 'bookmarks/delete-data', 'target' => 'bookmarks/delete-data'],
['path' => 'bookmarks/group-save-data', 'target' => 'bookmarks/group-save-data'],
['path' => 'bookmarks/group-delete-data', 'target' => 'bookmarks/group-delete-data'],
['path' => 'bookmarks/reorder-data', 'target' => 'bookmarks/reorder-data'],
],
'public_paths' => [],
'container_registrars' => [
\MintyPHP\Module\Bookmarks\BookmarksContainerRegistrar::class,
],
'ui_slots' => [
'aside.tab_panel' => [
[
'key' => 'bookmarks',
'label' => 'Bookmarks',
'icon' => 'bi-bookmark-star',
'href' => '',
'permission' => 'can_use_bookmarks',
'panel_template' => __DIR__ . '/templates/aside-bookmarks-panel.phtml',
'details_storage' => 'aside-bookmarks-groups-v1',
'order' => 120,
],
],
'topbar.right_item' => [
[
'key' => 'bookmarks-toggle',
'template' => __DIR__ . '/templates/topbar-bookmark-button.phtml',
'permission' => 'can_use_bookmarks',
'order' => 120,
],
],
'layout.body_end_template' => [
[
'key' => 'bookmarks-dialogs',
'template' => __DIR__ . '/templates/bookmark-dialogs.phtml',
'permission' => 'can_use_bookmarks',
'order' => 120,
],
],
'layout.head_style' => [
[
'key' => 'bookmarks-form-style',
'path' => 'modules/bookmarks/css/components/app-bookmark-form.css',
'permission' => 'can_use_bookmarks',
'order' => 120,
],
[
'key' => 'bookmarks-aside-style',
'path' => 'modules/bookmarks/css/layout/app-sidebar-bookmarks.css',
'permission' => 'can_use_bookmarks',
'order' => 121,
],
],
'search.resource_item' => [
[
'key' => 'bookmarks',
'label' => 'Bookmarks',
'base_url' => '#',
'permission' => 'can_use_bookmarks',
'order' => 130,
],
],
'runtime.component' => [
[
'key' => 'bookmark-save',
'script' => 'modules/bookmarks/js/components/app-bookmark-save.js',
'export' => 'initBookmarkSave',
'scope' => 'global',
'config_path' => 'components.bookmarkSave',
'default_config' => [
'openButtonSelector' => '[data-bookmark-open-dialog]',
'dialogSelector' => '[data-app-bookmark-dialog]',
'groupDialogSelector' => '[data-app-bookmark-group-dialog]',
],
'phase' => 'late',
'permission' => 'can_use_bookmarks',
'order' => 120,
],
[
'key' => 'bookmark-panel',
'script' => 'modules/bookmarks/js/components/app-bookmark-panel.js',
'export' => 'initBookmarkPanel',
'selector' => '[data-app-bookmark-panel]',
'config_path' => 'components.bookmarkPanel',
'default_config' => [
'selector' => '[data-app-bookmark-panel]',
],
'phase' => 'late',
'permission' => 'can_use_bookmarks',
'order' => 121,
],
],
],
'authorization_policies' => [
\MintyPHP\Module\Bookmarks\BookmarksAuthorizationPolicy::class,
],
'layout_capabilities' => [
'can_use_bookmarks' => 'bookmarks.use',
],
'search_resources' => [
\MintyPHP\Module\Bookmarks\Providers\BookmarksSearchProvider::class,
],
'asset_groups' => [],
'scheduler_jobs' => [],
'layout_context_providers' => [
\MintyPHP\Module\Bookmarks\Providers\BookmarksLayoutProvider::class,
],
'session_providers' => [
\MintyPHP\Module\Bookmarks\Providers\BookmarksSessionProvider::class,
],
'permissions' => [],
'migrations_path' => null,
];

View File

@@ -2,7 +2,7 @@
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session; use MintyPHP\Session;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -22,13 +22,27 @@ if (!Session::checkCsrfToken()) {
$session = app(SessionStoreInterface::class)->all(); $session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0); $userId = (int) ($session['user']['id'] ?? 0);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$bookmarkId = (int) requestInput()->body('id'); $bookmarkId = (int) requestInput()->body('id');
if ($bookmarkId <= 0) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_bookmark_id']);
return;
}
$service = app(BookmarkService::class); $service = app(BookmarkService::class);
$deleted = $service->deleteBookmark($userId, $bookmarkId); $deleted = $service->deleteBookmark($userId, $bookmarkId);
if ($deleted) { if ($deleted) {
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
Router::json(['ok' => true]);
return;
} }
Router::json(['ok' => $deleted]); http_response_code(404);
Router::json(['ok' => false, 'error' => 'not_found']);

View File

@@ -2,7 +2,7 @@
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session; use MintyPHP\Session;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) {
$session = app(SessionStoreInterface::class)->all(); $session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0); $userId = (int) ($session['user']['id'] ?? 0);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$groupId = (int) requestInput()->body('id'); $groupId = (int) requestInput()->body('id');
if ($groupId <= 0) { if ($groupId <= 0) {
http_response_code(400); http_response_code(400);
@@ -33,7 +39,7 @@ $service = app(BookmarkService::class);
$deleted = $service->deleteGroup($userId, $groupId); $deleted = $service->deleteGroup($userId, $groupId);
if ($deleted) { if ($deleted) {
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
Router::json(['ok' => true]); Router::json(['ok' => true]);
return; return;
} }

View File

@@ -2,7 +2,7 @@
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session; use MintyPHP\Session;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) {
$session = app(SessionStoreInterface::class)->all(); $session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0); $userId = (int) ($session['user']['id'] ?? 0);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$name = trim((string) requestInput()->body('name')); $name = trim((string) requestInput()->body('name'));
$icon = trim((string) requestInput()->body('icon')); $icon = trim((string) requestInput()->body('icon'));
$groupId = requestInput()->body('id'); $groupId = requestInput()->body('id');
@@ -31,7 +37,9 @@ $service = app(BookmarkService::class);
$result = $service->saveGroup($userId, $name, $icon, $groupId); $result = $service->saveGroup($userId, $name, $icon, $groupId);
if ($result['ok']) { if ($result['ok']) {
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
} else {
http_response_code(400);
} }
Router::json($result); Router::json($result);

View File

@@ -2,7 +2,7 @@
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session; use MintyPHP\Session;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -22,18 +22,17 @@ if (!Session::checkCsrfToken()) {
$session = app(SessionStoreInterface::class)->all(); $session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0); $userId = (int) ($session['user']['id'] ?? 0);
$type = trim((string) requestInput()->body('type')); if ($userId <= 0) {
$allowedTypes = ['groups', 'bookmarks', 'root']; http_response_code(401);
if (!in_array($type, $allowedTypes, true)) { Router::json(['ok' => false, 'error' => 'unauthorized']);
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_type']);
return; return;
} }
$type = trim((string) requestInput()->body('type'));
$rawItems = requestInput()->body('items'); $rawItems = requestInput()->body('items');
$items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []);
if (!is_array($items)) { $items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []);
if (!is_array($items) || $items === []) {
http_response_code(400); http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']); Router::json(['ok' => false, 'error' => 'invalid_items']);
return; return;
@@ -48,15 +47,18 @@ if ($type === 'root') {
foreach ($items as $item) { foreach ($items as $item) {
if (!is_array($item)) { if (!is_array($item)) {
continue; http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
} }
$kind = trim((string) ($item['kind'] ?? '')); $kind = trim((string) ($item['kind'] ?? ''));
$id = (int) ($item['id'] ?? 0); $id = (int) ($item['id'] ?? 0);
$sortOrder = (int) ($item['sort_order'] ?? 0); $sortOrder = (int) ($item['sort_order'] ?? 0);
if (($kind !== 'group' && $kind !== 'bookmark') || $id <= 0 || $sortOrder <= 0) { if (($kind !== 'group' && $kind !== 'bookmark') || $id <= 0 || $sortOrder <= 0) {
continue; http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
} }
$dedupeKey = $kind . ':' . $id; $dedupeKey = $kind . ':' . $id;
if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) { if (isset($seenRootIds[$dedupeKey]) || isset($seenSortOrders[$sortOrder])) {
http_response_code(400); http_response_code(400);
@@ -65,31 +67,27 @@ if ($type === 'root') {
} }
$seenRootIds[$dedupeKey] = true; $seenRootIds[$dedupeKey] = true;
$seenSortOrders[$sortOrder] = true; $seenSortOrders[$sortOrder] = true;
$rootPairs[] = [ $rootPairs[] = ['kind' => $kind, 'id' => $id, 'sort_order' => $sortOrder];
'kind' => $kind,
'id' => $id,
'sort_order' => $sortOrder,
];
} }
$pairCount = count($rootPairs); $result = $service->reorderRootFromItems($userId, $rootPairs);
if ($pairCount === 0 || $pairCount !== count($items)) { } else {
$pairs = [];
$seenIds = [];
foreach ($items as $item) {
if (!is_array($item)) {
http_response_code(400); http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']); Router::json(['ok' => false, 'error' => 'invalid_items']);
return; return;
} }
$ok = $service->reorderRoot($userId, $rootPairs);
} else {
$pairs = [];
$seenIds = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$id = (int) ($item['id'] ?? 0); $id = (int) ($item['id'] ?? 0);
$sortOrder = (int) ($item['sort_order'] ?? 0); $sortOrder = (int) ($item['sort_order'] ?? 0);
if ($id > 0 && $sortOrder > 0) { if ($id <= 0 || $sortOrder <= 0) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
}
if (isset($seenIds[$id])) { if (isset($seenIds[$id])) {
http_response_code(400); http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']); Router::json(['ok' => false, 'error' => 'invalid_items']);
@@ -98,25 +96,15 @@ if ($type === 'root') {
$seenIds[$id] = true; $seenIds[$id] = true;
$pairs[] = ['id' => $id, 'sort_order' => $sortOrder]; $pairs[] = ['id' => $id, 'sort_order' => $sortOrder];
} }
$result = $service->reorderFromItems($userId, $type, $pairs);
} }
$pairCount = count($pairs); if ($result['ok']) {
if ($pairCount === 0 || $pairCount !== count($items)) { app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
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]); Router::json(['ok' => true]);
return; return;
} }
http_response_code(400); http_response_code(400);
Router::json(['ok' => false, 'error' => 'reorder_failed']); Router::json($result);

View File

@@ -2,7 +2,7 @@
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session; use MintyPHP\Session;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -22,6 +22,12 @@ if (!Session::checkCsrfToken()) {
$session = app(SessionStoreInterface::class)->all(); $session = app(SessionStoreInterface::class)->all();
$userId = (int) ($session['user']['id'] ?? 0); $userId = (int) ($session['user']['id'] ?? 0);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$name = trim((string) requestInput()->body('name')); $name = trim((string) requestInput()->body('name'));
$url = trim((string) requestInput()->body('url')); $url = trim((string) requestInput()->body('url'));
$groupId = requestInput()->body('group_id'); $groupId = requestInput()->body('group_id');
@@ -31,7 +37,9 @@ $service = app(BookmarkService::class);
$result = $service->saveBookmark($userId, $name, $url, $groupId); $result = $service->saveBookmark($userId, $name, $url, $groupId);
if ($result['ok']) { if ($result['ok']) {
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
} else {
http_response_code(400);
} }
Router::json($result); Router::json($result);

View File

@@ -2,7 +2,7 @@
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router; use MintyPHP\Router;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session; use MintyPHP\Session;
use MintyPHP\Support\Guard; use MintyPHP\Support\Guard;
@@ -47,7 +47,7 @@ $service = app(BookmarkService::class);
$result = $service->updateBookmark($userId, $bookmarkId, $data); $result = $service->updateBookmark($userId, $bookmarkId, $data);
if ($result['ok']) { if ($result['ok']) {
app(SessionStoreInterface::class)->set('user_bookmarks', $service->listGroupedForUser($userId)); app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
} else { } else {
http_response_code(400); http_response_code(400);
} }

View File

@@ -0,0 +1,242 @@
<?php
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : [];
$bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : ['groups' => [], 'ungrouped' => []];
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
$currentPath = trim((string) ($bookmarkNav['current_path'] ?? ''));
?>
<div data-app-bookmark-panel data-app-component="bookmark-panel"
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
data-bookmark-group-delete-url="<?php e(lurl('bookmarks/group-delete-data')); ?>"
data-bookmark-delete-url="<?php e(lurl('bookmarks/delete-data')); ?>"
data-bookmark-msg-group-delete-confirm="<?php e(t('Delete group and keep bookmarks?')); ?>"
data-bookmark-msg-group-deleted="<?php e(t('Group deleted')); ?>"
data-bookmark-msg-group-updated="<?php e(t('Group updated')); ?>"
data-bookmark-msg-bookmark-delete-confirm="<?php e(t('Delete this bookmark?')); ?>"
data-bookmark-msg-bookmark-deleted="<?php e(t('Bookmark deleted')); ?>"
data-bookmark-msg-group-action-failed="<?php e(t('Group action failed')); ?>"
data-bookmark-msg-bookmark-action-failed="<?php e(t('Bookmark action failed')); ?>"
data-bookmark-msg-group-delete-failed="<?php e(t('Group delete failed')); ?>"
data-bookmark-msg-bookmark-delete-failed="<?php e(t('Bookmark action failed')); ?>"
data-bookmark-msg-reorder-saved="<?php e(t('Reorder saved')); ?>"
data-bookmark-msg-reorder-failed="<?php e(t('Reorder failed')); ?>"
data-bookmark-label-delete="<?php e(t('Delete')); ?>">
<?php if (!$bookmarkGroups && !$bookmarkUngrouped): ?>
<?php
$emptyState = [
'message' => t('No bookmarks yet'),
'size' => 'compact',
'align' => 'center',
];
require templatePath('partials/app-empty-state.phtml');
?>
<?php else: ?>
<?php
$bookmarkRootItems = [];
foreach ($bookmarkUngrouped as $bookmark) {
$bookmarkId = (int) ($bookmark['id'] ?? 0);
if ($bookmarkId <= 0) {
continue;
}
$bookmarkRootItems[] = [
'type' => 'bookmark',
'id' => $bookmarkId,
'sort_order' => (int) ($bookmark['sort_order'] ?? 0),
'bookmark' => $bookmark,
];
}
foreach ($bookmarkGroups as $group) {
$groupId = (int) ($group['id'] ?? 0);
if ($groupId <= 0) {
continue;
}
$bookmarkRootItems[] = [
'type' => 'group',
'id' => $groupId,
'sort_order' => (int) ($group['sort_order'] ?? 0),
'group' => $group,
];
}
usort($bookmarkRootItems, static function (array $a, array $b): int {
$sortCmp = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
if ($sortCmp !== 0) {
return $sortCmp;
}
$typeCmp = strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
if ($typeCmp !== 0) {
return $typeCmp;
}
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
});
$rootCount = count($bookmarkRootItems);
?>
<ul data-bookmark-root-list>
<?php foreach ($bookmarkRootItems as $rootIndex => $rootItem): ?>
<?php
$rootType = (string) ($rootItem['type'] ?? '');
$rootFirst = $rootIndex === 0;
$rootLast = $rootIndex === ($rootCount - 1);
?>
<?php if ($rootType === 'bookmark'): ?>
<?php
$bookmark = is_array($rootItem['bookmark'] ?? null) ? $rootItem['bookmark'] : [];
$bookmarkId = (int) ($bookmark['id'] ?? 0);
$bookmarkName = trim((string) ($bookmark['name'] ?? ''));
$bookmarkUrl = trim((string) ($bookmark['url'] ?? ''));
$canonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bookmarkUrl);
$bookmarkActive = $canonicalUrl !== '' && $currentPath === $canonicalUrl;
?>
<li class="app-sidebar-bookmark-root-item app-sidebar-bookmark-item"
data-bookmark-root-kind="bookmark"
data-bookmark-root-id="<?php e($bookmarkId); ?>"
data-bookmark-id="<?php e($bookmarkId); ?>"
data-bookmark-name="<?php e($bookmarkName); ?>"
data-bookmark-group-id="">
<a href="<?php e(lurl($bookmarkUrl)); ?>"
class="<?php e($bookmarkActive ? 'active' : ''); ?>"
<?php echo $bookmarkActive ? 'aria-current="page"' : ''; ?>>
<?php e($bookmarkName); ?>
</a>
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
<i class="bi bi-three-dots"></i>
</summary>
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
<li>
<button type="button" role="menuitem" data-bookmark-item-action="edit">
<?php e(t('Edit')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-up" data-bookmark-item-action-scope="root" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark up')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-down" data-bookmark-item-action-scope="root" <?php if ($rootLast): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark down')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
<?php e(t('Delete bookmark')); ?>
</button>
</li>
</ul>
</details>
</li>
<?php else: ?>
<?php
$group = is_array($rootItem['group'] ?? null) ? $rootItem['group'] : [];
$groupId = (int) ($group['id'] ?? 0);
$groupName = trim((string) ($group['name'] ?? ''));
$groupIcon = trim((string) ($group['icon'] ?? 'bi-folder'));
$groupBookmarks = is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : [];
?>
<li class="app-sidebar-group app-sidebar-bookmark-group app-sidebar-bookmark-root-item"
data-bookmark-root-kind="group"
data-bookmark-root-id="<?php e($groupId); ?>"
data-bookmark-group-id="<?php e($groupId); ?>"
data-bookmark-group-name="<?php e($groupName); ?>"
data-bookmark-group-icon="<?php e($groupIcon); ?>">
<div class="app-sidebar-bookmark-group-shell">
<div class="app-sidebar-bookmark-group-header">
<small class="app-sidebar-bookmark-group-label">
<i class="bi <?php e($groupIcon); ?>" aria-hidden="true"></i>
<span><?php e($groupName); ?></span>
</small>
<details class="app-sidebar-bookmark-action-menu app-sidebar-bookmark-group-menu" data-bookmark-action-menu>
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
<i class="bi bi-three-dots"></i>
</summary>
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Group actions')); ?>">
<li>
<button type="button" role="menuitem" data-bookmark-group-action="edit">
<?php e(t('Edit')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-group-action="move-up" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
<?php e(t('Move group up')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-group-action="move-down" <?php if ($rootLast): ?>disabled<?php endif; ?>>
<?php e(t('Move group down')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-group-action="delete">
<?php e(t('Delete group')); ?>
</button>
</li>
</ul>
</details>
</div>
<?php if ($groupBookmarks): ?>
<ul data-bookmark-group-bookmark-list>
<?php $groupBookmarkCount = count($groupBookmarks); ?>
<?php foreach ($groupBookmarks as $bookmarkIndex => $bookmark): ?>
<?php
$bookmarkId = (int) ($bookmark['id'] ?? 0);
$bookmarkName = trim((string) ($bookmark['name'] ?? ''));
$bookmarkUrl = trim((string) ($bookmark['url'] ?? ''));
$canonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bookmarkUrl);
$bookmarkActive = $canonicalUrl !== '' && $currentPath === $canonicalUrl;
$bookmarkFirst = $bookmarkIndex === 0;
$bookmarkLast = $bookmarkIndex === ($groupBookmarkCount - 1);
?>
<li class="app-sidebar-bookmark-item"
data-bookmark-id="<?php e($bookmarkId); ?>"
data-bookmark-name="<?php e($bookmarkName); ?>"
data-bookmark-group-id="<?php e($groupId); ?>">
<a href="<?php e(lurl($bookmarkUrl)); ?>"
class="<?php e($bookmarkActive ? 'active' : ''); ?>"
<?php echo $bookmarkActive ? 'aria-current="page"' : ''; ?>>
<?php e($bookmarkName); ?>
</a>
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
<i class="bi bi-three-dots"></i>
</summary>
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
<li>
<button type="button" role="menuitem" data-bookmark-item-action="edit">
<?php e(t('Edit')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-up" <?php if ($bookmarkFirst): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark up')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-down" <?php if ($bookmarkLast): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark down')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
<?php e(t('Delete bookmark')); ?>
</button>
</li>
</ul>
</details>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>

View File

@@ -1,11 +1,11 @@
<?php <?php
use MintyPHP\Http\SessionStoreInterface; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Service\Bookmark\BookmarkService;
$bmSessionData = is_array(app(SessionStoreInterface::class)->get('user_bookmarks')) ? app(SessionStoreInterface::class)->get('user_bookmarks') : [];
$bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['groups'] : [];
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : [];
$bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : [];
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
?> ?>
<dialog <dialog
data-app-bookmark-dialog data-app-bookmark-dialog
@@ -37,18 +37,19 @@ $bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['
<label for="bookmark-group"><?php e(t('Assign to group')); ?></label> <label for="bookmark-group"><?php e(t('Assign to group')); ?></label>
<select id="bookmark-group" name="group_id" data-bookmark-group-select> <select id="bookmark-group" name="group_id" data-bookmark-group-select>
<option value=""><?php e(t('No group')); ?></option> <option value=""><?php e(t('No group')); ?></option>
<?php foreach ($bmSessionGroups as $grp): <?php foreach ($bookmarkGroups as $group):
$grpId = (int) ($grp['id'] ?? 0); $groupId = (int) ($group['id'] ?? 0);
$grpName = trim((string) ($grp['name'] ?? '')); $groupName = trim((string) ($group['name'] ?? ''));
if ($grpId <= 0 || $grpName === '') { continue; } if ($groupId <= 0 || $groupName === '') { continue; }
?> ?>
<option value="<?php e($grpId); ?>"><?php e($grpName); ?></option> <option value="<?php e($groupId); ?>"><?php e($groupName); ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<button type="button" class="outline secondary app-bookmark-new-group-btn" data-bookmark-new-group> <button type="button" class="outline secondary app-bookmark-new-group-btn" data-bookmark-new-group>
<i class="bi bi-plus"></i> <?php e(t('New group')); ?> <i class="bi bi-plus"></i> <?php e(t('New group')); ?>
</button> </button>
<footer> <footer>
<button type="button" class="secondary outline" data-bookmark-dialog-close><?php e(t('Cancel')); ?></button>
<button type="submit" class="app-action-success" data-bookmark-submit-label><?php e(t('Save')); ?></button> <button type="submit" class="app-action-success" data-bookmark-submit-label><?php e(t('Save')); ?></button>
</footer> </footer>
</form> </form>
@@ -89,6 +90,7 @@ $bmSessionGroups = is_array($bmSessionData['groups'] ?? null) ? $bmSessionData['
</div> </div>
<input type="hidden" name="icon" value="bi-folder"> <input type="hidden" name="icon" value="bi-folder">
<footer> <footer>
<button type="button" class="secondary outline" data-bookmark-group-dialog-close><?php e(t('Cancel')); ?></button>
<button type="submit" class="app-action-success" data-bookmark-group-submit-label><?php e(t('Save')); ?></button> <button type="submit" class="app-action-success" data-bookmark-group-submit-label><?php e(t('Save')); ?></button>
</footer> </footer>
</form> </form>

View File

@@ -0,0 +1,46 @@
<?php
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : [];
$bookmarkNav = is_array($layoutNav['bookmarks.nav'] ?? null) ? $layoutNav['bookmarks.nav'] : [];
$bookmarkData = is_array($bookmarkNav['grouped'] ?? null) ? $bookmarkNav['grouped'] : ['groups' => [], 'ungrouped' => []];
$bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : [];
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
$currentPath = trim((string) ($bookmarkNav['current_path'] ?? ''));
$existing = null;
if ($currentPath !== '') {
foreach ($bookmarkUngrouped as $bookmark) {
$bookmarkUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bookmark['url'] ?? ''));
if ($bookmarkUrl !== '' && $bookmarkUrl === $currentPath) {
$existing = $bookmark;
break;
}
}
if ($existing === null) {
foreach ($bookmarkGroups as $group) {
foreach ((is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : []) as $bookmark) {
$bookmarkUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bookmark['url'] ?? ''));
if ($bookmarkUrl !== '' && $bookmarkUrl === $currentPath) {
$existing = $bookmark;
break 2;
}
}
}
}
}
?>
<li>
<button type="button" data-bookmark-open-dialog
aria-label="<?php e($existing ? t('Edit bookmark') : t('Bookmarks')); ?>"
data-tooltip="<?php e($existing ? t('Edit bookmark') : t('Bookmarks')); ?>" data-tooltip-pos="bottom"
<?php if ($existing): ?>
data-bookmark-existing-id="<?php e((int) ($existing['id'] ?? 0)); ?>"
data-bookmark-existing-name="<?php e(trim((string) ($existing['name'] ?? ''))); ?>"
data-bookmark-existing-group="<?php e($existing['group_id'] ?? ''); ?>"
<?php endif; ?>>
<i class="bi <?php e($existing ? 'bi-bookmark-check-fill' : 'bi-bookmark-plus'); ?>"></i>
</button>
</li>

View File

@@ -1,11 +1,11 @@
<?php <?php
namespace MintyPHP\Tests\Service\Bookmark; namespace MintyPHP\Tests\Module\Bookmarks\Service;
use MintyPHP\Repository\User\BookmarkGroupRepositoryInterface; use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
use MintyPHP\Repository\User\BookmarkNavigationRepositoryInterface; use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
use MintyPHP\Repository\User\BookmarkRepositoryInterface; use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
use MintyPHP\Service\Bookmark\BookmarkService; use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;

View File

@@ -0,0 +1,88 @@
<?php
namespace MintyPHP\Tests\Module\Bookmarks\Support;
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
use PHPUnit\Framework\TestCase;
class BookmarkUrlNormalizerTest extends TestCase
{
public function testCanonicalizeRelativeNormalizesPathAndQueryOrder(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('/admin//users/?b=2&a=1');
$this->assertSame('admin/users?a=1&b=2', $actual);
}
public function testCanonicalizeRelativeSortsMultiValueQueryKeys(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?roles=2&roles=1&search=max');
$this->assertSame('admin/users?roles=1&roles=2&search=max', $actual);
}
public function testCanonicalizeRelativeRejectsAbsoluteUrl(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('https://example.com/admin/users');
$this->assertSame('', $actual);
}
public function testCanonicalizeRelativeRemovesFragment(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?a=1#section-2');
$this->assertSame('admin/users?a=1', $actual);
}
public function testCanonicalizeRequestUriStripsLocaleBaseAndSortsQuery(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/de/admin/users?b=2&a=1', '/de/');
$this->assertSame('admin/users?a=1&b=2', $actual);
}
public function testCanonicalizeRequestUriHandlesAppBaseAndLocale(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/core/de/address-book?tenants=b&tenants=a', '/core/de/');
$this->assertSame('address-book?tenants=a&tenants=b', $actual);
}
public function testCanonicalizeRelativeRejectsDataUri(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('data:text/html,<script>alert(1)</script>'));
}
public function testCanonicalizeRelativeRejectsJavaScriptUri(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('javascript:alert(1)'));
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('JavaScript:alert(document.cookie)'));
}
public function testCanonicalizeRelativeRejectsFileUri(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('file:///etc/passwd'));
}
public function testCanonicalizeRelativeRejectsVbScriptUri(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('vbscript:MsgBox("xss")'));
}
public function testCanonicalizeRelativeRejectsNullBytes(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative("admin/users\x00javascript:alert(1)"));
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative("\x00"));
}
public function testCanonicalizeRelativeRejectsProtocolRelativeUrl(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('//evil.com/payload'));
}
public function testCanonicalizeRelativeHandlesDeepPathTraversal(): void
{
$this->assertSame('', BookmarkUrlNormalizer::canonicalizeRelative('admin/users/../../../../etc/passwd'));
}
}

View File

@@ -53,18 +53,9 @@
dialog[data-app-bookmark-dialog] form footer, dialog[data-app-bookmark-dialog] form footer,
dialog[data-app-bookmark-group-dialog] form footer { dialog[data-app-bookmark-group-dialog] form footer {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
padding-top: var(--app-spacing); padding-top: var(--app-spacing);
} }
dialog[data-app-bookmark-dialog] form footer button,
dialog[data-app-bookmark-group-dialog] form footer button {
margin: 0;
}
.app-bookmark-delete-btn { .app-bookmark-delete-btn {
color: var(--app-del-color, #c62828); color: var(--app-del-color, #c62828);
border-color: var(--app-del-color, #c62828); border-color: var(--app-del-color, #c62828);

View File

@@ -0,0 +1,185 @@
@layer layout {
.app-sidebar .app-sidebar-bookmark-item > a > i {
flex-shrink: 0;
width: 16px;
text-align: center;
}
li.app-sidebar-bookmark-root-item.app-sidebar-bookmark-item:has(+ .app-sidebar-group) {
margin-bottom: var(--app-spacing);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item {
display: flex;
align-items: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-shell {
position: relative;
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: var(--app-spacing);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
margin: 0;
color: var(--app-muted-color);
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > span {
flex: 1;
min-width: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > i {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu {
margin: 0;
margin-right: 5px;
position: relative;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu summary:after {
display: none;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary {
list-style: none;
border: 0;
background: transparent;
color: var(--app-muted-color);
padding: 0.25rem;
min-width: 1.5rem;
min-height: 1.5rem;
line-height: 1;
margin: 0;
border-radius: 3px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 120ms ease,
background 120ms ease,
opacity 120ms ease;
opacity: 0;
pointer-events: none;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item:hover .app-sidebar-bookmark-action-menu > summary,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item:focus-within .app-sidebar-bookmark-action-menu > summary,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header:hover .app-sidebar-bookmark-action-menu > summary,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header:focus-within .app-sidebar-bookmark-action-menu > summary,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu[open] > summary,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary:focus-visible {
opacity: 1;
pointer-events: auto;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary::-webkit-details-marker {
display: none;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary i {
font-size: 0.95rem;
pointer-events: none;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu[open] > summary,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary:hover,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary:focus-visible {
color: var(--app-contrast);
background: color-mix(in srgb, var(--app-contrast) 10%, transparent);
}
@media (hover: none), (pointer: coarse) {
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu > summary {
opacity: 1;
pointer-events: auto;
}
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list {
position: absolute;
inset-inline-end: 0;
inset-block-start: calc(100% + 4px);
min-width: 10rem;
max-width: calc(220px - 2 * var(--app-spacing));
border: 1px solid var(--app-border);
border-radius: var(--app-border-radius);
background: var(--app-card-bg, var(--app-background-color));
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.14),
0 1px 3px rgba(0, 0, 0, 0.08);
padding: 0.25rem;
z-index: 10;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list > li {
margin: 0;
padding: 0;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button {
width: 100%;
border: 0;
background: transparent;
color: inherit;
text-align: start;
padding: 0.375rem 0.5rem;
margin: 0;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
font-size: 0.8125rem;
line-height: 1.4;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button:hover,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button:focus-visible {
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list button[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list li:has(> .app-sidebar-bookmark-action-danger) {
margin-top: 0.2rem;
padding-top: 0.2rem;
border-top: 1px solid var(--app-border);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger {
color: var(--app-del-color, #c62828);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger:hover,
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list .app-sidebar-bookmark-action-danger:focus-visible {
background: rgba(198, 40, 40, 0.12);
}
}

View File

@@ -4,14 +4,19 @@
* - Reorder bookmarks inside groups (up/down) * - Reorder bookmarks inside groups (up/down)
* - Edit/Delete groups and bookmarks with confirm dialog * - Edit/Delete groups and bookmarks with confirm dialog
*/ */
import { showAsyncFlash } from './app-async-flash.js'; import { showAsyncFlash } from '../../../../js/components/app-async-flash.js';
import { warnOnce } from '../core/app-dom.js'; import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js';
import { confirmDialog } from '../core/app-confirm-dialog.js'; import { confirmDialog } from '../../../../js/core/app-confirm-dialog.js';
export function init() { export function initBookmarkPanel(root = document, options = {}) {
const panel = document.querySelector('[data-aside-panel="bookmarks"]'); const host = resolveHost(root);
const panelSelector = String(options.selector || '[data-aside-panel="bookmarks"]').trim() || '[data-aside-panel="bookmarks"]';
const panel = host.matches?.(panelSelector) ? host : host.querySelector(panelSelector);
if (!(panel instanceof HTMLElement)) { if (!(panel instanceof HTMLElement)) {
return; return { destroy: () => {} };
}
if (panel.dataset.bookmarkPanelBound === '1' && panel._bookmarkPanelApi) {
return panel._bookmarkPanelApi;
} }
const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim(); const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim();
@@ -19,16 +24,19 @@ export function init() {
const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim(); const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim();
if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) { if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) {
warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' }); warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' });
return; return { destroy: () => {} };
} }
const root = document.documentElement; const documentRoot = document.documentElement;
const csrfKey = String(root.dataset.csrfKey || '').trim(); const csrfKey = String(documentRoot.dataset.csrfKey || '').trim();
const csrfToken = String(root.dataset.csrfToken || '').trim(); const csrfToken = String(documentRoot.dataset.csrfToken || '').trim();
if (!csrfKey || !csrfToken) { if (!csrfKey || !csrfToken) {
warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' }); warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' });
return; return { destroy: () => {} };
} }
const abortController = new AbortController();
const { signal } = abortController;
const timerIds = [];
const messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?'; const messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?';
const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted'; const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted';
@@ -93,7 +101,8 @@ export function init() {
if (String(successMessage).trim() !== '') { if (String(successMessage).trim() !== '') {
showAsyncFlash('success', successMessage, 2500); showAsyncFlash('success', successMessage, 2500);
} }
window.setTimeout(() => window.location.reload(), 450); const timerId = window.setTimeout(() => window.location.reload(), 450);
timerIds.push(timerId);
}; };
const directChildren = (container, className) => { const directChildren = (container, className) => {
@@ -494,7 +503,7 @@ export function init() {
void reorderBookmark(itemActionButton); void reorderBookmark(itemActionButton);
} }
} }
}); }, { signal });
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
if (!(event.target instanceof Node)) { if (!(event.target instanceof Node)) {
@@ -504,5 +513,17 @@ export function init() {
if (!clickedInsideMenu) { if (!clickedInsideMenu) {
closeActionMenus(); closeActionMenus();
} }
}); }, { signal });
const destroy = () => {
abortController.abort();
timerIds.forEach((timerId) => window.clearTimeout(timerId));
timerIds.length = 0;
delete panel.dataset.bookmarkPanelBound;
delete panel._bookmarkPanelApi;
};
const api = { destroy };
panel.dataset.bookmarkPanelBound = '1';
panel._bookmarkPanelApi = api;
return api;
} }

View File

@@ -3,17 +3,20 @@
* Bookmark dialog (create/update) + group dialog (create/update). * Bookmark dialog (create/update) + group dialog (create/update).
*/ */
import { showAsyncFlash } from './app-async-flash.js'; import { showAsyncFlash } from '../../../../js/components/app-async-flash.js';
import { warnOnce } from '../core/app-dom.js'; import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js';
import { getAppBase } from '../pages/app-list-utils.js'; import { getAppBase } from '../../../../js/pages/app-list-utils.js';
export function initBookmarkSave(root = document) { export function initBookmarkSave(root = document, options = {}) {
const host = root && typeof root.querySelector === 'function' ? root : document; const host = resolveHost(root);
const openBtn = host.querySelector('[data-bookmark-open-dialog]') || document.querySelector('[data-bookmark-open-dialog]'); const openButtonSelector = String(options.openButtonSelector || '[data-bookmark-open-dialog]').trim() || '[data-bookmark-open-dialog]';
const bookmarkDialog = host.matches?.('[data-app-bookmark-dialog]') const bookmarkDialogSelector = String(options.dialogSelector || '[data-app-bookmark-dialog]').trim() || '[data-app-bookmark-dialog]';
const groupDialogSelector = String(options.groupDialogSelector || '[data-app-bookmark-group-dialog]').trim() || '[data-app-bookmark-group-dialog]';
const openBtn = host.querySelector(openButtonSelector);
const bookmarkDialog = host.matches?.(bookmarkDialogSelector)
? host ? host
: (host.querySelector('[data-app-bookmark-dialog]') || document.querySelector('[data-app-bookmark-dialog]')); : host.querySelector(bookmarkDialogSelector);
if (!(bookmarkDialog instanceof HTMLDialogElement) || !(openBtn instanceof HTMLButtonElement)) { if (!(bookmarkDialog instanceof HTMLDialogElement)) {
return { destroy: () => {} }; return { destroy: () => {} };
} }
if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) { if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) {
@@ -25,7 +28,7 @@ export function initBookmarkSave(root = document) {
return { destroy: () => {} }; return { destroy: () => {} };
} }
const groupDialog = document.querySelector('[data-app-bookmark-group-dialog]'); const groupDialog = host.querySelector(groupDialogSelector);
const groupForm = groupDialog instanceof HTMLDialogElement const groupForm = groupDialog instanceof HTMLDialogElement
? groupDialog.querySelector('[data-bookmark-group-save-form]') ? groupDialog.querySelector('[data-bookmark-group-save-form]')
: null; : null;
@@ -148,6 +151,17 @@ export function initBookmarkSave(root = document) {
}; };
const configureBookmarkModeFromTopbar = () => { const configureBookmarkModeFromTopbar = () => {
if (!(openBtn instanceof HTMLButtonElement)) {
applyBookmarkModeUi(false);
editingBookmarkId = null;
bookmarkForm.reset();
if (nameInput) {
const title = document.title.replace(/\s*[|–—].*$/, '').trim();
nameInput.value = title || '';
}
return;
}
const isEditMode = openBtn.hasAttribute('data-bookmark-existing-id'); const isEditMode = openBtn.hasAttribute('data-bookmark-existing-id');
applyBookmarkModeUi(isEditMode); applyBookmarkModeUi(isEditMode);
@@ -220,12 +234,14 @@ export function initBookmarkSave(root = document) {
}; };
// Bookmark dialog open // Bookmark dialog open
if (openBtn instanceof HTMLButtonElement) {
openBtn.addEventListener('click', () => { openBtn.addEventListener('click', () => {
configureBookmarkModeFromTopbar(); configureBookmarkModeFromTopbar();
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) { if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
nameInput.select(); nameInput.select();
} }
}, { signal }); }, { signal });
}
document.addEventListener('app:bookmark-edit', (event) => { document.addEventListener('app:bookmark-edit', (event) => {
const detail = event instanceof CustomEvent ? event.detail : null; const detail = event instanceof CustomEvent ? event.detail : null;
@@ -607,8 +623,7 @@ export function initBookmarkSave(root = document) {
} }
function currentRelativeUrl() { function currentRelativeUrl() {
const baseEl = document.querySelector('base'); const basePath = new URL(getAppBase()).pathname;
const basePath = baseEl ? new URL(baseEl.href).pathname : '/';
const full = window.location.pathname + window.location.search; const full = window.location.pathname + window.location.search;
if (full.startsWith(basePath)) { if (full.startsWith(basePath)) {
return full.slice(basePath.length); return full.slice(basePath.length);

View File

@@ -0,0 +1,227 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class FrontendComponentRuntimeContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testEntrypointsUseRuntimeRegistrationWithoutSideeffectComponentImports(): void
{
$defaultEntry = $this->readProjectFile('web/js/app-init.js');
$loginEntry = $this->readProjectFile('web/js/app-login-init.js');
$this->assertStringContainsString("import { createComponentRuntime } from './core/app-component-runtime.js';", $defaultEntry);
$this->assertStringContainsString("import { createComponentRuntime } from './core/app-component-runtime.js';", $loginEntry);
$this->assertDoesNotMatchRegularExpression('/import\s+[\'"]\.\/components\//', $defaultEntry);
$this->assertDoesNotMatchRegularExpression('/import\s+[\'"]\.\/components\//', $loginEntry);
$this->assertStringContainsString("runtime.register('sidebar-toggle', initSidebarToggle", $defaultEntry);
$this->assertStringContainsString("runtime.register('global-search', initGlobalSearch", $defaultEntry);
$this->assertStringContainsString("await mountModuleComponentsByPhase('late');", $defaultEntry);
$this->assertStringContainsString('moduleRuntimeComponents', $defaultEntry);
$this->assertStringContainsString("selector: '[data-app-component=\"tabs\"]'", $defaultEntry);
$this->assertStringContainsString('runtime.mountAll();', $defaultEntry);
$this->assertStringContainsString("runtime.register('password-hints', initPasswordHints", $loginEntry);
$this->assertStringContainsString("runtime.register('password-toggle', initPasswordToggles", $loginEntry);
$this->assertStringContainsString("runtime.register('login-submit-lock', initLoginSubmitLock", $loginEntry);
$this->assertStringContainsString('runtime.mountAll();', $loginEntry);
}
public function testRuntimeMigratedComponentsDoNotAutoInitialize(): void
{
$componentFiles = [
'web/js/components/app-flash-auto-dismiss.js',
'web/js/components/app-fslightbox-refresh.js',
'web/js/components/app-password-hints.js',
'web/js/components/app-copy-badge.js',
'web/js/components/app-multiselect-init.js',
'web/js/components/app-nav-history.js',
'web/js/components/app-toggle-sidebar.js',
'web/js/components/app-toggle-aside-panels.js',
'web/js/components/app-toggle-details-aside.js',
'web/js/components/app-global-search.js',
'web/js/components/app-theme-toggle.js',
'web/js/components/app-contrast-toggle.js',
'web/js/components/app-confirm-actions.js',
'web/js/components/app-details-state.js',
'web/js/components/app-tenant-switcher.js',
'web/js/components/app-color-default-toggle.js',
'web/js/components/app-custom-field-options-toggle.js',
'web/js/components/app-tenant-sso-toggle.js',
'web/js/components/app-settings-telemetry.js',
'web/js/components/app-standard-detail-page.js',
'web/js/components/app-auto-submit.js',
'modules/bookmarks/web/js/components/app-bookmark-save.js',
'modules/bookmarks/web/js/components/app-bookmark-panel.js',
'web/js/components/app-password-toggle.js',
'web/js/components/app-page-editor.js',
];
foreach ($componentFiles as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('DOMContentLoaded', $content, $file);
$this->assertStringNotContainsString("if (document.readyState === 'loading')", $content, $file);
}
}
public function testMigratedComponentsExposeDestroyCleanupPaths(): void
{
$sidebar = $this->readProjectFile('web/js/components/app-toggle-sidebar.js');
$aside = $this->readProjectFile('web/js/components/app-toggle-aside-panels.js');
$search = $this->readProjectFile('web/js/components/app-global-search.js');
$bookmarkSave = $this->readProjectFile('modules/bookmarks/web/js/components/app-bookmark-save.js');
$bookmarkPanel = $this->readProjectFile('modules/bookmarks/web/js/components/app-bookmark-panel.js');
$multiSelect = $this->readProjectFile('web/js/components/app-multiselect-init.js');
$this->assertStringContainsString('destroy: () => {', $sidebar);
$this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onShortcut));", $sidebar);
$this->assertStringContainsString('destroy: () => {', $aside);
$this->assertStringContainsString("cleanupFns.push(() => document.removeEventListener('keydown', onKeyDown));", $aside);
$this->assertStringContainsString('const destroy = () => {', $search);
$this->assertStringContainsString("document.removeEventListener('keydown', onDocumentKeydown);", $search);
$this->assertStringContainsString('const destroy = () => {', $bookmarkSave);
$this->assertStringContainsString('abortController.abort();', $bookmarkSave);
$this->assertStringContainsString('const destroy = () => {', $bookmarkPanel);
$this->assertStringContainsString('abortController.abort();', $bookmarkPanel);
$this->assertStringNotContainsString('export const init =', $bookmarkSave);
$this->assertStringNotContainsString('export const init =', $bookmarkPanel);
$this->assertStringContainsString('return {', $multiSelect);
$this->assertStringContainsString('destroy: () => {', $multiSelect);
}
public function testBookmarkLegacyEntrypointFileIsRemoved(): void
{
$this->assertFileDoesNotExist($this->projectRootPath() . '/web/js/components/app-bookmark-init.js');
}
public function testRuntimeStorageContractUsesSharedUiStorageHelper(): void
{
$files = [
'web/js/components/app-toggle-sidebar.js',
'web/js/components/app-toggle-aside-panels.js',
'web/js/components/app-toggle-details-aside.js',
'web/js/components/app-contrast-toggle.js',
'web/js/components/app-tabs.js',
'web/js/core/app-details-open-state.js',
'web/js/core/app-grid-factory.js',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('window.localStorage', $content, $file);
$this->assertStringContainsString('createUiStorage', $content, $file);
}
}
public function testHostBoundComponentsExposeDataAppComponentMarkupHosts(): void
{
$defaultTemplate = $this->readProjectFile('templates/default.phtml');
$loginTemplate = $this->readProjectFile('templates/login.phtml');
$flashPartial = $this->readProjectFile('templates/partials/app-flash.phtml');
$topbarPartial = $this->readProjectFile('templates/partials/app-topbar.phtml');
$asideIconBar = $this->readProjectFile('templates/partials/app-main-aside-icon-bar.phtml');
$asidePartial = $this->readProjectFile('templates/partials/app-main-aside.phtml');
$bookmarkDialog = $this->readProjectFile('modules/bookmarks/templates/bookmark-dialogs.phtml');
$bookmarkPanelTemplate = $this->readProjectFile('modules/bookmarks/templates/aside-bookmarks-panel.phtml');
$sessionWarningDialog = $this->readProjectFile('templates/partials/app-session-warning-dialog.phtml');
$this->assertStringContainsString('id="page-config-app-components"', $defaultTemplate);
$this->assertStringContainsString('id="page-config-login-components"', $loginTemplate);
$this->assertStringNotContainsString("assetVersion('js/components/app-bookmark-init.js')", $defaultTemplate);
$this->assertStringContainsString('data-app-component="flash-auto-dismiss"', $flashPartial);
$this->assertStringContainsString('data-app-component="nav-history"', $topbarPartial);
$this->assertStringContainsString('data-app-component="tenant-switcher"', $topbarPartial);
$this->assertStringContainsString('data-app-component="theme-controls"', $topbarPartial);
$this->assertStringContainsString('data-app-component="contrast-toggle"', $topbarPartial);
$this->assertStringContainsString('data-app-component="details-aside-toggle"', $topbarPartial);
$this->assertStringContainsString('data-app-component="aside-panels"', $asideIconBar);
$this->assertStringContainsString('data-app-component="sidebar-toggle"', $asidePartial);
$this->assertStringContainsString('data-app-component="global-search"', $asidePartial);
$this->assertStringContainsString('data-app-component="bookmark-panel"', $bookmarkPanelTemplate);
$this->assertStringContainsString('data-app-component="bookmark-save"', $bookmarkDialog);
$this->assertStringContainsString('data-app-component="session-warning"', $sessionWarningDialog);
}
public function testTabsHostsUseDataAppComponentContract(): void
{
$files = [
'modules/addressbook/pages/address-book/view(default).phtml',
'pages/admin/departments/_form.phtml',
'pages/admin/permissions/_form.phtml',
'pages/admin/roles/_form.phtml',
'pages/admin/settings/index(default).phtml',
'pages/admin/stats/index(default).phtml',
'pages/admin/tenants/_form.phtml',
'pages/admin/users/_form.phtml',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('data-tabs', $content, $file);
$this->assertStringContainsString('data-app-component="tabs"', $content, $file);
}
}
public function testPageEditorUsesPageModuleEntrypointWithPageConfig(): void
{
$pageEditor = $this->readProjectFile('web/js/components/app-page-editor.js');
$pageEditorEntry = $this->readProjectFile('web/js/pages/app-page-editor-entry.js');
$adminPage = $this->readProjectFile('pages/page/index(default).phtml');
$publicPage = $this->readProjectFile('pages/public-page/index(page).phtml');
$this->assertStringContainsString('export function initPageEditor(root = document, config = {})', $pageEditor);
$this->assertStringNotContainsString('const form = document.querySelector', $pageEditor);
$this->assertStringContainsString("readPageConfig('page-editor')", $pageEditorEntry);
$this->assertStringContainsString("assetVersion('js/pages/app-page-editor-entry.js')", $adminPage);
$this->assertStringContainsString("assetVersion('js/pages/app-page-editor-entry.js')", $publicPage);
$this->assertStringNotContainsString("assetVersion('js/components/app-page-editor.js')", $adminPage);
$this->assertStringNotContainsString("assetVersion('js/components/app-page-editor.js')", $publicPage);
$this->assertStringContainsString('id="page-config-page-editor"', $adminPage);
$this->assertStringContainsString('id="page-config-page-editor"', $publicPage);
}
public function testNoCustomWindowGlobalsRemainInWebJs(): void
{
$violations = $this->findPatternMatchesInFiles(
'web/js',
'/window\.(AppSidebar|AppAsidePanels|requestFsLightboxRefresh|__fsLightboxRefreshPending|APP_ASSET_BASE|APP_BASE)\b/',
['js']
);
$this->assertSame([], $violations, 'Custom window globals found in web/js: ' . implode(', ', $violations));
}
public function testHostBoundRuntimeComponentsAreRootStrictWithoutDocumentFallbackQuery(): void
{
$files = [
'web/js/components/app-global-search.js',
'modules/bookmarks/web/js/components/app-bookmark-panel.js',
'web/js/components/app-tabs.js',
];
foreach ($files as $file) {
$content = $this->readProjectFile($file);
$this->assertDoesNotMatchRegularExpression('/\|\|\s*document\.querySelector/', $content, $file);
}
}
public function testDomBindingUtilitiesExposeDestroyLifecycle(): void
{
$activeFilterChips = $this->readProjectFile('web/js/components/app-active-filter-chips.js');
$multiselectCascade = $this->readProjectFile('web/js/components/app-multiselect-cascade.js');
$this->assertStringContainsString('return { render, destroy };', $activeFilterChips);
$this->assertStringContainsString('return {', $multiselectCascade);
$this->assertStringContainsString('destroy: () => {', $multiselectCascade);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
final class NoBookmarksHardcodingTest extends TestCase
{
public function testCoreTemplatesHaveNoBookmarkSpecificMarkup(): void
{
$files = [
dirname(__DIR__, 2) . '/templates/default.phtml',
dirname(__DIR__, 2) . '/templates/partials/app-topbar.phtml',
dirname(__DIR__, 2) . '/templates/partials/app-main-aside.phtml',
dirname(__DIR__, 2) . '/templates/partials/app-main-aside-icon-bar.phtml',
];
foreach ($files as $file) {
$content = file_get_contents($file);
self::assertIsString($content);
self::assertStringNotContainsString('aside-tab-bookmarks', $content, $file);
self::assertStringNotContainsString('aside-panel-bookmarks', $content, $file);
self::assertStringNotContainsString('data-app-bookmark-dialog', $content, $file);
self::assertStringNotContainsString('bookmarks.nav', $content, $file);
self::assertStringNotContainsString("components.bookmark", $content, $file);
}
}
public function testCoreBookmarkEntrypointsAreRemoved(): void
{
$root = dirname(__DIR__, 2);
self::assertFileDoesNotExist($root . '/templates/partials/app-bookmark-dialog.phtml');
self::assertFileDoesNotExist($root . '/web/js/components/app-bookmark-save.js');
self::assertFileDoesNotExist($root . '/web/js/components/app-bookmark-panel.js');
self::assertFileDoesNotExist($root . '/web/css/components/app-bookmark-form.css');
self::assertDirectoryDoesNotExist($root . '/pages/bookmarks');
}
public function testCoreNoLongerContainsBookmarkServiceDirectory(): void
{
$root = dirname(__DIR__, 2);
self::assertDirectoryDoesNotExist(
$root . '/lib/Service/Bookmark',
'Bookmark service classes should live in modules/bookmarks/lib, not in Core lib/Service'
);
}
public function testCoreNoLongerContainsBookmarkRepositoryFiles(): void
{
$root = dirname(__DIR__, 2);
$repoDir = $root . '/lib/Repository/User';
if (!is_dir($repoDir)) {
self::addToAssertionCount(1);
return;
}
$entries = scandir($repoDir) ?: [];
$bookmarkFiles = array_filter($entries, static fn (string $f): bool => str_starts_with($f, 'Bookmark'));
self::assertSame(
[],
array_values($bookmarkFiles),
'Core lib/Repository/User/ still contains Bookmark files: ' . implode(', ', $bookmarkFiles)
);
}
public function testCoreNoLongerContainsBookmarkUrlNormalizer(): void
{
$root = dirname(__DIR__, 2);
self::assertFileDoesNotExist(
$root . '/lib/Support/BookmarkUrlNormalizer.php',
'BookmarkUrlNormalizer should live in modules/bookmarks/lib, not in Core lib/Support'
);
}
public function testCoreAuthLifecycleHasNoLegacyBookmarkSessionKeyWrites(): void
{
$file = dirname(__DIR__, 2) . '/lib/Service/Auth/AuthSessionTenantContextService.php';
$content = file_get_contents($file);
self::assertIsString($content);
self::assertStringNotContainsString('user_bookmarks', $content);
self::assertStringNotContainsString('module.bookmarks.grouped', $content);
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace MintyPHP\Tests\Support;
use MintyPHP\Support\BookmarkUrlNormalizer;
use PHPUnit\Framework\TestCase;
class BookmarkUrlNormalizerTest extends TestCase
{
public function testCanonicalizeRelativeNormalizesPathAndQueryOrder(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('/admin//users/?b=2&a=1');
$this->assertSame('admin/users?a=1&b=2', $actual);
}
public function testCanonicalizeRelativeSortsMultiValueQueryKeys(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?roles=2&roles=1&search=max');
$this->assertSame('admin/users?roles=1&roles=2&search=max', $actual);
}
public function testCanonicalizeRelativeRejectsAbsoluteUrl(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('https://example.com/admin/users');
$this->assertSame('', $actual);
}
public function testCanonicalizeRelativeRemovesFragment(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRelative('admin/users?a=1#section-2');
$this->assertSame('admin/users?a=1', $actual);
}
public function testCanonicalizeRequestUriStripsLocaleBaseAndSortsQuery(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/de/admin/users?b=2&a=1', '/de/');
$this->assertSame('admin/users?a=1&b=2', $actual);
}
public function testCanonicalizeRequestUriHandlesAppBaseAndLocale(): void
{
$actual = BookmarkUrlNormalizer::canonicalizeRequestUri('/core/de/address-book?tenants=b&tenants=a', '/core/de/');
$this->assertSame('address-book?tenants=a&tenants=b', $actual);
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\Module\Bookmarks\BookmarksAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use PHPUnit\Framework\TestCase;
final class BookmarksAuthorizationPolicyTest extends TestCase
{
public function testImplementsAuthorizationPolicyContract(): void
{
$policy = new BookmarksAuthorizationPolicy();
self::assertInstanceOf(AuthorizationPolicyInterface::class, $policy);
}
public function testSupportsBookmarksAbilityOnly(): void
{
$policy = new BookmarksAuthorizationPolicy();
self::assertTrue($policy->supports(BookmarksAuthorizationPolicy::ABILITY_USE));
self::assertFalse($policy->supports('addressbook.view'));
}
public function testAuthorizeAllowsAuthenticatedActors(): void
{
$policy = new BookmarksAuthorizationPolicy();
$decision = $policy->authorize(BookmarksAuthorizationPolicy::ABILITY_USE, [
'actor_user_id' => 7,
]);
self::assertTrue($decision->isAllowed());
}
public function testAuthorizeDeniesMissingActor(): void
{
$policy = new BookmarksAuthorizationPolicy();
$decision = $policy->authorize(BookmarksAuthorizationPolicy::ABILITY_USE, []);
self::assertFalse($decision->isAllowed());
self::assertSame(403, $decision->status());
}
}

View File

@@ -1,10 +0,0 @@
/**
* app-bookmark-init.js
* Initializes bookmark save form and sidebar panel.
*/
import { init as initBookmarkSave } from './app-bookmark-save.js';
import { init as initBookmarkPanel } from './app-bookmark-panel.js';
initBookmarkSave();
initBookmarkPanel();

1
web/modules/bookmarks Symbolic link
View File

@@ -0,0 +1 @@
/var/www/modules/bookmarks/web