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

@@ -0,0 +1,190 @@
<?php
namespace MintyPHP\Module\Bookmarks\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
class BookmarkGroupRepository implements BookmarkGroupRepositoryInterface
{
private function unwrapList(mixed $rows): array
{
return RepositoryArrayHelper::unwrapList($rows, 'user_bookmark_groups');
}
public function listByUser(int $userId, array $options = []): array
{
if ($userId <= 0) {
return [];
}
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options, 100, 1, 100);
$rows = DB::select(
'select id, user_id, name, icon, sort_order, created, modified from user_bookmark_groups where user_id = ? order by sort_order, id limit ? offset ?',
(string) $userId,
(string) $limit,
(string) $offset
);
return $this->unwrapList($rows);
}
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;
}
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace MintyPHP\Module\Bookmarks\Repository;
interface BookmarkGroupRepositoryInterface
{
/**
* @param array{limit?: int, offset?: int} $options
* @return list<array<string, mixed>>
*/
public function listByUser(int $userId, array $options = []): array;
public function countByUser(int $userId): int;
/** @return array<string, mixed>|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<array{id: int, sort_order: int}> $idOrderPairs */
public function updateSortOrders(int $userId, array $idOrderPairs): bool;
}

View File

@@ -0,0 +1,125 @@
<?php
namespace MintyPHP\Module\Bookmarks\Repository;
use MintyPHP\DB;
class BookmarkNavigationRepository implements BookmarkNavigationRepositoryInterface
{
public function nextRootSortOrder(int $userId): int
{
if ($userId <= 0) {
return 1;
}
$max = DB::selectValue(
'select GREATEST(
COALESCE((select max(sort_order) from user_bookmark_groups where user_id = ?), 0),
COALESCE((select max(sort_order) from user_bookmarks where user_id = ? and group_id is NULL), 0)
)',
(string) $userId,
(string) $userId
);
return max(1, ((int) $max) + 1);
}
public function updateRootSortOrders(int $userId, array $rootOrderPairs): bool
{
if ($userId <= 0 || $rootOrderPairs === []) {
return false;
}
$groupPairs = [];
$bookmarkPairs = [];
$groupIds = [];
$bookmarkIds = [];
foreach ($rootOrderPairs as $pair) {
$kind = trim((string) $pair['kind']);
$id = (int) $pair['id'];
$sortOrder = (int) $pair['sort_order'];
if ($id <= 0 || $sortOrder <= 0 || ($kind !== 'group' && $kind !== 'bookmark')) {
return false;
}
if ($kind === 'group') {
$groupPairs[] = ['id' => $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;
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace MintyPHP\Module\Bookmarks\Repository;
interface BookmarkNavigationRepositoryInterface
{
public function nextRootSortOrder(int $userId): int;
/** @param list<array{kind: 'group'|'bookmark', id: int, sort_order: int}> $rootOrderPairs */
public function updateRootSortOrders(int $userId, array $rootOrderPairs): bool;
}

View File

@@ -0,0 +1,274 @@
<?php
namespace MintyPHP\Module\Bookmarks\Repository;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
use MintyPHP\Repository\Support\RepositoryArrayHelper;
class BookmarkRepository implements BookmarkRepositoryInterface
{
private function unwrapList(mixed $rows): array
{
return RepositoryArrayHelper::unwrapList($rows, 'user_bookmarks');
}
public function listByUser(int $userId, array $options = []): array
{
if ($userId <= 0) {
return [];
}
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options, 100, 1, 100);
$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 limit ? offset ?',
(string) $userId,
(string) $limit,
(string) $offset
);
return $this->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;
}
}
/** @return list<array<string, mixed>> */
public function listByGroup(int $userId, int $groupId): array
{
if ($userId <= 0 || $groupId <= 0) {
return [];
}
$rows = DB::select(
'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) $groupId
);
return $this->unwrapList($rows);
}
public function unsetGroupId(int $bookmarkId, int $userId, int $sortOrder): bool
{
if ($bookmarkId <= 0 || $userId <= 0 || $sortOrder <= 0) {
return false;
}
$updated = DB::update(
'update user_bookmarks set group_id = NULL, sort_order = ? where id = ? and user_id = ?',
(string) $sortOrder,
(string) $bookmarkId,
(string) $userId
);
return $updated !== false;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace MintyPHP\Module\Bookmarks\Repository;
interface BookmarkRepositoryInterface
{
/**
* @param array{limit?: int, offset?: int} $options
* @return list<array<string, mixed>>
*/
public function listByUser(int $userId, array $options = []): array;
public function nextBookmarkSortOrder(int $userId, ?int $groupId): int;
public function countByUser(int $userId): int;
/** @return array<string, mixed>|false */
public function findById(int $id, int $userId): array|false;
/** @return array<string, mixed>|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<array{id: int, sort_order: int}> $idOrderPairs */
public function updateSortOrders(int $userId, array $idOrderPairs): bool;
/** @return list<array<string, mixed>> */
public function listByGroup(int $userId, int $groupId): array;
public function unsetGroupId(int $bookmarkId, int $userId, int $sortOrder): bool;
}

View File

@@ -0,0 +1,32 @@
<?php
namespace MintyPHP\Module\Bookmarks\Service;
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepository;
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
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 BookmarkRepositoryFactory
{
private ?BookmarkRepository $bookmarkRepository = null;
private ?BookmarkGroupRepository $groupRepository = null;
private ?BookmarkNavigationRepository $navigationRepository = null;
public function createBookmarkRepository(): BookmarkRepositoryInterface
{
return $this->bookmarkRepository ??= new BookmarkRepository();
}
public function createBookmarkGroupRepository(): BookmarkGroupRepositoryInterface
{
return $this->groupRepository ??= new BookmarkGroupRepository();
}
public function createBookmarkNavigationRepository(): BookmarkNavigationRepositoryInterface
{
return $this->navigationRepository ??= new BookmarkNavigationRepository();
}
}

View File

@@ -0,0 +1,414 @@
<?php
namespace MintyPHP\Module\Bookmarks\Service;
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
use MintyPHP\Module\Bookmarks\Support\BookmarkUrlNormalizer;
class BookmarkService
{
private const MAX_BOOKMARKS = 50;
private const MAX_GROUPS = 10;
private const NAME_MAX = 120;
private const GROUP_NAME_MAX = 100;
private const URL_MAX = 500;
private const ALLOWED_GROUP_ICONS = [
'bi-house', 'bi-people', 'bi-person', 'bi-folder', 'bi-file-text',
'bi-gear', 'bi-shield-lock', 'bi-bar-chart', 'bi-envelope', 'bi-calendar',
'bi-clock', 'bi-star', 'bi-heart', 'bi-flag', 'bi-bookmark',
'bi-pin-map', 'bi-lightning', 'bi-database', 'bi-globe', 'bi-code-slash',
];
public function __construct(
private readonly BookmarkRepositoryInterface $bookmarkRepository,
private readonly BookmarkGroupRepositoryInterface $groupRepository,
private readonly BookmarkNavigationRepositoryInterface $navigationRepository
) {
}
/** @return list<string> */
public static function allowedGroupIcons(): array
{
return self::ALLOWED_GROUP_ICONS;
}
/**
* @return array{groups: list<array<string, mixed>>, ungrouped: list<array<string, mixed>>}
*/
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<string, mixed>}
*/
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->bookmarkRepository->findByUserAndUrl($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<string, mixed> $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<array{id: int, sort_order: int}> $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<string, mixed>}
*/
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
{
$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;
}
$nextSort++;
}
return $this->groupRepository->delete($groupId, $userId);
}
/** @param list<array{id: int, sort_order: int}> $idOrderPairs */
public function reorderGroups(int $userId, array $idOrderPairs): bool
{
return $this->groupRepository->updateSortOrders($userId, $idOrderPairs);
}
/** @param list<array{kind: 'group'|'bookmark', id: int, sort_order: int}> $rootOrderPairs */
public function reorderRoot(int $userId, array $rootOrderPairs): bool
{
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
{
$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;
}
}

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

@@ -0,0 +1,154 @@
<?php
namespace MintyPHP\Module\Bookmarks\Support;
final class BookmarkUrlNormalizer
{
public static function canonicalizeRelative(string $url): string
{
$url = trim($url);
if ($url === '' || str_contains($url, "\x00")) {
return '';
}
// Strip fragments: they are client-side only and should not
// differentiate bookmarks during deduplication.
$hashPos = strpos($url, '#');
if ($hashPos !== false) {
$url = substr($url, 0, $hashPos);
}
if ($url === '') {
return '';
}
if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $url) || str_starts_with($url, '//')) {
return '';
}
$parts = parse_url($url);
if ($parts === false) {
return '';
}
$path = self::normalizePath((string) ($parts['path'] ?? ''));
$query = self::normalizeQuery((string) ($parts['query'] ?? ''));
if ($path === '' && $query === '') {
return '';
}
if ($query === '') {
return $path;
}
return $path !== '' ? $path . '?' . $query : '?' . $query;
}
public static function canonicalizeRequestUri(string $requestUri, string $localeBase): string
{
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?? '');
$requestQuery = (string) (parse_url($requestUri, PHP_URL_QUERY) ?? '');
$basePath = (string) (parse_url($localeBase, PHP_URL_PATH) ?? '');
$basePath = '/' . trim($basePath, '/');
if ($basePath !== '/' && ($requestPath === $basePath || str_starts_with($requestPath, $basePath . '/'))) {
$requestPath = substr($requestPath, strlen($basePath));
}
$relative = ltrim($requestPath, '/');
if ($requestQuery !== '') {
$relative .= '?' . $requestQuery;
}
return self::canonicalizeRelative($relative);
}
private static function normalizePath(string $path): string
{
$path = ltrim(trim($path), '/');
if ($path === '') {
return '';
}
$collapsed = preg_replace('#/+#', '/', $path);
if ($collapsed === null) {
return '';
}
$segments = explode('/', $collapsed);
$normalized = [];
$depth = 0;
foreach ($segments as $segment) {
$segment = trim($segment);
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
if ($depth <= 0) {
return '';
}
array_pop($normalized);
$depth--;
continue;
}
$normalized[] = $segment;
$depth++;
}
return implode('/', $normalized);
}
private static function normalizeQuery(string $query): string
{
$query = trim($query);
if ($query === '') {
return '';
}
$parsed = [];
foreach (explode('&', $query) as $pair) {
if ($pair === '') {
continue;
}
$separatorPos = strpos($pair, '=');
if ($separatorPos === false) {
$rawKey = $pair;
$rawValue = '';
} else {
$rawKey = substr($pair, 0, $separatorPos);
$rawValue = substr($pair, $separatorPos + 1);
}
$key = rawurldecode(str_replace('+', ' ', $rawKey));
$value = rawurldecode(str_replace('+', ' ', $rawValue));
if ($key === '') {
continue;
}
$parsed[$key][] = $value;
}
if ($parsed === []) {
return '';
}
ksort($parsed, SORT_STRING);
$normalizedPairs = [];
foreach ($parsed as $key => $values) {
sort($values, SORT_STRING);
foreach ($values as $value) {
$normalizedPairs[] = rawurlencode($key) . '=' . rawurlencode((string) $value);
}
}
return implode('&', $normalizedPairs);
}
}

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

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$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);
$deleted = $service->deleteBookmark($userId, $bookmarkId);
if ($deleted) {
app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
Router::json(['ok' => true]);
return;
}
http_response_code(404);
Router::json(['ok' => false, 'error' => 'not_found']);

View File

@@ -0,0 +1,48 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$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('module.bookmarks.grouped', $service->listGroupedForUser($userId));
Router::json(['ok' => true]);
return;
}
http_response_code(404);
Router::json(['ok' => false, 'error' => 'not_found']);

View File

@@ -0,0 +1,45 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$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('module.bookmarks.grouped', $service->listGroupedForUser($userId));
} else {
http_response_code(400);
}
Router::json($result);

View File

@@ -0,0 +1,110 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$type = trim((string) requestInput()->body('type'));
$rawItems = requestInput()->body('items');
$items = is_array($rawItems) ? $rawItems : (is_string($rawItems) ? json_decode($rawItems, true) : []);
if (!is_array($items) || $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)) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
}
$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) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
}
$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];
}
$result = $service->reorderRootFromItems($userId, $rootPairs);
} else {
$pairs = [];
$seenIds = [];
foreach ($items as $item) {
if (!is_array($item)) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
}
$id = (int) ($item['id'] ?? 0);
$sortOrder = (int) ($item['sort_order'] ?? 0);
if ($id <= 0 || $sortOrder <= 0) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_items']);
return;
}
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];
}
$result = $service->reorderFromItems($userId, $type, $pairs);
}
if ($result['ok']) {
app(SessionStoreInterface::class)->set('module.bookmarks.grouped', $service->listGroupedForUser($userId));
Router::json(['ok' => true]);
return;
}
http_response_code(400);
Router::json($result);

View File

@@ -0,0 +1,45 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$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('module.bookmarks.grouped', $service->listGroupedForUser($userId));
} else {
http_response_code(400);
}
Router::json($result);

View File

@@ -0,0 +1,55 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Router;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
Guard::requireLogin();
if (requestInput()->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);
if ($userId <= 0) {
http_response_code(401);
Router::json(['ok' => false, 'error' => 'unauthorized']);
return;
}
$bookmarkId = (int) requestInput()->body('id');
if ($bookmarkId <= 0) {
http_response_code(400);
Router::json(['ok' => false, 'error' => 'invalid_bookmark_id']);
return;
}
$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('module.bookmarks.grouped', $service->listGroupedForUser($userId));
} else {
http_response_code(400);
}
Router::json($result);

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

@@ -0,0 +1,98 @@
<?php
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
$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
data-app-bookmark-dialog
data-app-component="bookmark-save"
data-max-message="<?php e(t('Maximum bookmarks reached')); ?>"
data-title-create="<?php e(t('Bookmarks')); ?>"
data-title-edit="<?php e(t('Bookmarks')); ?>"
data-label-save="<?php e(t('Save')); ?>"
data-label-update="<?php e(t('Save')); ?>"
data-msg-saved="<?php e(t('Bookmark saved')); ?>"
data-msg-updated="<?php e(t('Bookmark updated')); ?>"
data-msg-group-created="<?php e(t('Group created')); ?>"
data-msg-error-generic="<?php e(t('Bookmark action failed')); ?>"
data-msg-group-create-failed="<?php e(t('Group action failed')); ?>"
data-msg-name-required="<?php e(t('Bookmark name is required')); ?>"
data-msg-url-required="<?php e(t('Bookmark URL is invalid')); ?>"
data-msg-group-not-found="<?php e(t('Group not found')); ?>"
aria-labelledby="app-bookmark-dialog-title"
>
<article class="app-bookmark-dialog">
<header>
<h2 id="app-bookmark-dialog-title"><?php e(t('Bookmarks')); ?></h2>
<button type="button" class="close" data-bookmark-dialog-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
</header>
<form data-bookmark-save-form>
<label for="bookmark-name"><?php e(t('Name')); ?></label>
<input type="text" id="bookmark-name" name="name" maxlength="120" required
placeholder="<?php e(t('Bookmark name')); ?>">
<label for="bookmark-group"><?php e(t('Assign to group')); ?></label>
<select id="bookmark-group" name="group_id" data-bookmark-group-select>
<option value=""><?php e(t('No group')); ?></option>
<?php foreach ($bookmarkGroups as $group):
$groupId = (int) ($group['id'] ?? 0);
$groupName = trim((string) ($group['name'] ?? ''));
if ($groupId <= 0 || $groupName === '') { continue; }
?>
<option value="<?php e($groupId); ?>"><?php e($groupName); ?></option>
<?php endforeach; ?>
</select>
<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')); ?>
</button>
<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>
</footer>
</form>
</article>
</dialog>
<dialog
data-app-bookmark-group-dialog
data-title-create="<?php e(t('New group')); ?>"
data-title-edit="<?php e(t('Edit group')); ?>"
data-label-save="<?php e(t('Save')); ?>"
data-label-update="<?php e(t('Save')); ?>"
data-max-message="<?php e(t('Maximum groups reached')); ?>"
data-msg-created="<?php e(t('Group created')); ?>"
data-msg-updated="<?php e(t('Group updated')); ?>"
data-msg-error-generic="<?php e(t('Group action failed')); ?>"
data-msg-name-required="<?php e(t('Group name is required')); ?>"
aria-labelledby="app-bookmark-group-dialog-title"
>
<article class="app-bookmark-dialog app-bookmark-group-dialog">
<header>
<h2 id="app-bookmark-group-dialog-title"><?php e(t('New group')); ?></h2>
<button type="button" class="close" data-bookmark-group-dialog-close aria-label="<?php e(t('Close')); ?>" title="<?php e(t('Close')); ?>"></button>
</header>
<form data-bookmark-group-save-form>
<label for="bookmark-group-name"><?php e(t('Group name')); ?></label>
<input type="text" id="bookmark-group-name" name="name" maxlength="100" required
placeholder="<?php e(t('Group name')); ?>">
<label><?php e(t('Icon')); ?></label>
<div class="app-bookmark-group-icon-picker" data-bookmark-group-icon-picker>
<?php foreach (BookmarkService::allowedGroupIcons() as $allowedIcon): ?>
<button type="button" class="app-bookmark-group-icon-option"
data-icon="<?php e($allowedIcon); ?>"
aria-label="<?php e($allowedIcon); ?>">
<i class="bi <?php e($allowedIcon); ?>"></i>
</button>
<?php endforeach; ?>
</div>
<input type="hidden" name="icon" value="bi-folder">
<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>
</footer>
</form>
</article>
</dialog>

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

@@ -0,0 +1,491 @@
<?php
namespace MintyPHP\Tests\Module\Bookmarks\Service;
use MintyPHP\Module\Bookmarks\Repository\BookmarkGroupRepositoryInterface;
use MintyPHP\Module\Bookmarks\Repository\BookmarkNavigationRepositoryInterface;
use MintyPHP\Module\Bookmarks\Repository\BookmarkRepositoryInterface;
use MintyPHP\Module\Bookmarks\Service\BookmarkService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class BookmarkServiceTest extends TestCase
{
private BookmarkRepositoryInterface&MockObject $bookmarkRepo;
private BookmarkGroupRepositoryInterface&MockObject $groupRepo;
private BookmarkNavigationRepositoryInterface&MockObject $navigationRepo;
private BookmarkService $service;
protected function setUp(): void
{
$this->bookmarkRepo = $this->createMock(BookmarkRepositoryInterface::class);
$this->groupRepo = $this->createMock(BookmarkGroupRepositoryInterface::class);
$this->navigationRepo = $this->createMock(BookmarkNavigationRepositoryInterface::class);
$this->service = new BookmarkService($this->bookmarkRepo, $this->groupRepo, $this->navigationRepo);
}
public function testSaveBookmarkHappyPath(): void
{
$this->bookmarkRepo->method('countByUser')->willReturn(0);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1);
$this->bookmarkRepo->method('create')->willReturn(42);
$result = $this->service->saveBookmark(1, 'Home', 'admin', null);
$this->assertTrue($result['ok']);
$this->assertSame('created', $result['mode']);
$this->assertSame(42, $result['bookmark']['id']);
$this->assertSame('Home', $result['bookmark']['name']);
$this->assertSame('admin', $result['bookmark']['url']);
$this->assertNull($result['bookmark']['group_id']);
$this->assertArrayNotHasKey('icon', $result['bookmark']);
}
public function testSaveBookmarkUpsertsExistingBookmarkByExactUrl(): void
{
$this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users?a=1&b=2')->willReturn([
'id' => 9,
'url' => 'admin/users?a=1&b=2',
]);
$this->bookmarkRepo->expects($this->once())->method('update')->with(
9,
1,
[
'name' => 'Users',
'group_id' => null,
]
)->willReturn(true);
$this->bookmarkRepo->expects($this->never())->method('create');
$result = $this->service->saveBookmark(1, 'Users', 'admin/users?b=2&a=1', null);
$this->assertTrue($result['ok']);
$this->assertSame('updated', $result['mode']);
$this->assertSame(9, $result['bookmark']['id']);
$this->assertSame('admin/users?a=1&b=2', $result['bookmark']['url']);
}
public function testSaveBookmarkUpsertsExistingBookmarkAndMovesToNewGroupAtEnd(): void
{
$this->groupRepo->expects($this->once())->method('findById')->with(3, 1)->willReturn([
'id' => 3,
'name' => 'Sales',
]);
$this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users')->willReturn([
'id' => 9,
'url' => 'admin/users',
'group_id' => 1,
]);
$this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 3)->willReturn(12);
$this->bookmarkRepo->expects($this->once())->method('update')->with(
9,
1,
[
'name' => 'Users',
'group_id' => 3,
'sort_order' => 12,
]
)->willReturn(true);
$this->bookmarkRepo->expects($this->never())->method('create');
$result = $this->service->saveBookmark(1, 'Users', 'admin/users', 3);
$this->assertTrue($result['ok']);
$this->assertSame('updated', $result['mode']);
}
public function testSaveBookmarkCreatesWhenNoExactUrlMatch(): void
{
$this->bookmarkRepo->expects($this->once())->method('findByUserAndUrl')->with(1, 'admin/users?a=1&b=2')->willReturn(false);
$this->bookmarkRepo->method('countByUser')->willReturn(0);
$this->navigationRepo->method('nextRootSortOrder')->willReturn(1);
$this->bookmarkRepo->expects($this->once())->method('create')->willReturn(7);
$result = $this->service->saveBookmark(1, 'Users', 'admin/users?a=1&b=2', null);
$this->assertTrue($result['ok']);
$this->assertSame('created', $result['mode']);
$this->assertSame(7, $result['bookmark']['id']);
}
public function testSaveBookmarkRejectsEmptyName(): void
{
$result = $this->service->saveBookmark(1, '', 'admin', null);
$this->assertFalse($result['ok']);
$this->assertSame('name_required', $result['error']);
}
public function testSaveBookmarkRejectsEmptyUrl(): void
{
$result = $this->service->saveBookmark(1, 'Test', '', null);
$this->assertFalse($result['ok']);
$this->assertSame('url_required', $result['error']);
}
public function testSaveBookmarkRejectsAbsoluteUrl(): void
{
$result = $this->service->saveBookmark(1, 'External', 'https://evil.com', null);
$this->assertFalse($result['ok']);
$this->assertSame('url_required', $result['error']);
}
public function testSaveBookmarkRejectsProtocolRelativeUrl(): void
{
$result = $this->service->saveBookmark(1, 'External', '//evil.com', null);
$this->assertFalse($result['ok']);
$this->assertSame('url_required', $result['error']);
}
public function testSaveBookmarkMaxLimitReached(): void
{
$this->bookmarkRepo->method('countByUser')->willReturn(50);
$result = $this->service->saveBookmark(1, 'Test', 'admin', null);
$this->assertFalse($result['ok']);
$this->assertSame('max_reached', $result['error']);
}
public function testSaveBookmarkWithGroupValidatesOwnership(): void
{
$this->bookmarkRepo->method('countByUser')->willReturn(0);
$this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(false);
$result = $this->service->saveBookmark(1, 'Test', 'admin', 5);
$this->assertFalse($result['ok']);
$this->assertSame('group_not_found', $result['error']);
}
public function testSaveBookmarkWithValidGroup(): void
{
$this->bookmarkRepo->method('countByUser')->willReturn(0);
$this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 5)->willReturn(7);
$this->bookmarkRepo->expects($this->once())->method('create')->with([
'user_id' => 1,
'group_id' => 5,
'name' => 'Test',
'url' => 'admin',
'sort_order' => 7,
])->willReturn(10);
$this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(['id' => 5, 'name' => 'Dev']);
$result = $this->service->saveBookmark(1, 'Test', 'admin', 5);
$this->assertTrue($result['ok']);
$this->assertSame(5, $result['bookmark']['group_id']);
}
public function testUpdateBookmarkNotFound(): void
{
$this->bookmarkRepo->method('findById')->willReturn(false);
$result = $this->service->updateBookmark(1, 999, ['name' => 'New Name']);
$this->assertFalse($result['ok']);
$this->assertSame('not_found', $result['error']);
}
public function testUpdateBookmarkRejectsEmptyName(): void
{
$this->bookmarkRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Old']);
$result = $this->service->updateBookmark(1, 1, ['name' => '']);
$this->assertFalse($result['ok']);
$this->assertSame('name_required', $result['error']);
}
public function testUpdateBookmarkNoChanges(): void
{
$this->bookmarkRepo->method('findById')->willReturn(['id' => 1, 'name' => 'Old']);
$result = $this->service->updateBookmark(1, 1, []);
$this->assertTrue($result['ok']);
}
public function testUpdateBookmarkGroupChangeAppendsToTargetGroup(): void
{
$this->bookmarkRepo->expects($this->once())->method('findById')->with(5, 1)->willReturn([
'id' => 5,
'name' => 'Users',
'group_id' => 1,
]);
$this->groupRepo->expects($this->once())->method('findById')->with(2, 1)->willReturn([
'id' => 2,
'name' => 'New',
]);
$this->bookmarkRepo->expects($this->once())->method('nextBookmarkSortOrder')->with(1, 2)->willReturn(9);
$this->bookmarkRepo->expects($this->once())->method('update')->with(5, 1, [
'group_id' => 2,
'sort_order' => 9,
])->willReturn(true);
$result = $this->service->updateBookmark(1, 5, ['group_id' => 2]);
$this->assertTrue($result['ok']);
}
public function testUpdateBookmarkMoveToUngroupedUsesRootSortOrder(): void
{
$this->bookmarkRepo->expects($this->once())->method('findById')->with(5, 1)->willReturn([
'id' => 5,
'name' => 'Users',
'group_id' => 2,
]);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(17);
$this->bookmarkRepo->expects($this->once())->method('update')->with(5, 1, [
'group_id' => null,
'sort_order' => 17,
])->willReturn(true);
$result = $this->service->updateBookmark(1, 5, ['group_id' => null]);
$this->assertTrue($result['ok']);
}
public function testDeleteBookmark(): void
{
$this->bookmarkRepo->expects($this->any())->method('delete')->with(5, 1)->willReturn(true);
$this->assertTrue($this->service->deleteBookmark(1, 5));
}
public function testSaveGroupHappyPath(): void
{
$this->groupRepo->method('countByUser')->willReturn(0);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(4);
$this->groupRepo->expects($this->once())->method('create')->with([
'user_id' => 1,
'name' => 'Development',
'icon' => 'bi-people',
'sort_order' => 4,
])->willReturn(3);
$result = $this->service->saveGroup(1, 'Development', 'bi-people');
$this->assertTrue($result['ok']);
$this->assertSame('created', $result['mode']);
$this->assertSame(3, $result['group']['id']);
$this->assertSame('Development', $result['group']['name']);
$this->assertSame('bi-people', $result['group']['icon']);
}
public function testSaveGroupRejectsEmptyName(): void
{
$result = $this->service->saveGroup(1, '', 'bi-folder');
$this->assertFalse($result['ok']);
$this->assertSame('name_required', $result['error']);
}
public function testSaveGroupFallsBackToDefaultIcon(): void
{
$this->groupRepo->method('countByUser')->willReturn(0);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1);
$this->groupRepo->expects($this->once())->method('create')->with([
'user_id' => 1,
'name' => 'Development',
'icon' => 'bi-folder',
'sort_order' => 1,
])->willReturn(11);
$result = $this->service->saveGroup(1, 'Development', 'invalid-icon');
$this->assertTrue($result['ok']);
$this->assertSame('bi-folder', $result['group']['icon']);
}
public function testSaveGroupMaxLimitReached(): void
{
$this->groupRepo->method('countByUser')->willReturn(10);
$result = $this->service->saveGroup(1, 'New Group', 'bi-folder');
$this->assertFalse($result['ok']);
$this->assertSame('max_reached', $result['error']);
}
public function testSaveGroupRenameExisting(): void
{
$this->groupRepo->expects($this->any())->method('findById')->with(5, 1)->willReturn(['id' => 5, 'name' => 'Old']);
$this->groupRepo->expects($this->once())
->method('update')
->with(5, 1, ['name' => 'Renamed', 'icon' => 'bi-heart'])
->willReturn(true);
$result = $this->service->saveGroup(1, 'Renamed', 'bi-heart', 5);
$this->assertTrue($result['ok']);
$this->assertSame('updated', $result['mode']);
$this->assertSame(5, $result['group']['id']);
$this->assertSame('Renamed', $result['group']['name']);
$this->assertSame('bi-heart', $result['group']['icon']);
}
public function testDeleteGroupUnsetsGroupAndDeletes(): void
{
$this->bookmarkRepo->expects($this->once())->method('listByGroup')->with(1, 5)->willReturn([
['id' => 10, 'name' => 'A', 'url' => 'admin', 'group_id' => 5, 'sort_order' => 1],
['id' => 11, 'name' => 'B', 'url' => 'admin/users', 'group_id' => 5, 'sort_order' => 2],
]);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(20);
$this->bookmarkRepo->expects($this->exactly(2))->method('unsetGroupId')
->willReturnCallback(fn (int $bmId, int $userId, int $sort) => match ([$bmId, $userId]) {
[10, 1] => $sort === 20,
[11, 1] => $sort === 21,
default => false,
});
$this->groupRepo->expects($this->once())->method('delete')->with(5, 1)->willReturn(true);
$this->assertTrue($this->service->deleteGroup(1, 5));
}
public function testDeleteGroupReturnsFalseWhenUnsetFails(): void
{
$this->bookmarkRepo->expects($this->once())->method('listByGroup')->with(1, 5)->willReturn([
['id' => 10, 'name' => 'A', 'url' => 'admin', 'group_id' => 5, 'sort_order' => 1],
]);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(20);
$this->bookmarkRepo->expects($this->once())->method('unsetGroupId')->with(10, 1, 20)->willReturn(false);
$this->groupRepo->expects($this->never())->method('delete');
$this->assertFalse($this->service->deleteGroup(1, 5));
}
public function testDeleteGroupWithNoBookmarksDeletesDirectly(): void
{
$this->bookmarkRepo->expects($this->once())->method('listByGroup')->with(1, 5)->willReturn([]);
$this->navigationRepo->expects($this->once())->method('nextRootSortOrder')->with(1)->willReturn(1);
$this->bookmarkRepo->expects($this->never())->method('unsetGroupId');
$this->groupRepo->expects($this->once())->method('delete')->with(5, 1)->willReturn(true);
$this->assertTrue($this->service->deleteGroup(1, 5));
}
public function testReorderGroupsDelegatesToRepository(): void
{
$pairs = [
['id' => 4, 'sort_order' => 1],
['id' => 6, 'sort_order' => 2],
];
$this->groupRepo->expects($this->once())->method('updateSortOrders')->with(1, $pairs)->willReturn(true);
$this->assertTrue($this->service->reorderGroups(1, $pairs));
}
public function testReorderBookmarksReturnsFalseOnRepositoryFailure(): void
{
$pairs = [
['id' => 11, 'sort_order' => 1],
['id' => 7, 'sort_order' => 2],
];
$this->bookmarkRepo->expects($this->once())->method('updateSortOrders')->with(1, $pairs)->willReturn(false);
$this->assertFalse($this->service->reorderBookmarks(1, $pairs));
}
public function testReorderRootDelegatesToNavigationRepository(): void
{
$pairs = [
['kind' => 'bookmark', 'id' => 11, 'sort_order' => 1],
['kind' => 'group', 'id' => 2, 'sort_order' => 2],
];
$this->navigationRepo->expects($this->once())
->method('updateRootSortOrders')
->with(1, $pairs)
->willReturn(true);
$this->assertTrue($this->service->reorderRoot(1, $pairs));
}
public function testListGroupedForUser(): void
{
$this->groupRepo->method('listByUser')->willReturn([
['id' => 1, 'name' => 'Dev', 'icon' => 'bi-folder', 'sort_order' => 0],
]);
$this->bookmarkRepo->method('listByUser')->willReturn([
['id' => 10, 'name' => 'Home', 'url' => 'admin', 'group_id' => null, 'sort_order' => 0],
['id' => 11, 'name' => 'Users', 'url' => 'admin/users', 'group_id' => 1, 'sort_order' => 0],
]);
$result = $this->service->listGroupedForUser(1);
$this->assertCount(1, $result['groups']);
$this->assertSame('Dev', $result['groups'][0]['name']);
$this->assertSame('bi-folder', $result['groups'][0]['icon']);
$this->assertCount(1, $result['groups'][0]['bookmarks']);
$this->assertSame('Users', $result['groups'][0]['bookmarks'][0]['name']);
$this->assertArrayNotHasKey('icon', $result['groups'][0]['bookmarks'][0]);
$this->assertCount(1, $result['ungrouped']);
$this->assertSame('Home', $result['ungrouped'][0]['name']);
$this->assertArrayNotHasKey('icon', $result['ungrouped'][0]);
}
public function testReorderFromItemsRejectsInvalidType(): void
{
$result = $this->service->reorderFromItems(1, 'invalid', []);
$this->assertFalse($result['ok']);
$this->assertSame('invalid_type', $result['error']);
}
public function testReorderFromItemsRejectsEmptyItems(): void
{
$result = $this->service->reorderFromItems(1, 'bookmarks', []);
$this->assertFalse($result['ok']);
$this->assertSame('invalid_items', $result['error']);
}
public function testReorderFromItemsBookmarksHappyPath(): void
{
$this->bookmarkRepo->expects($this->once())->method('updateSortOrders')->with(1, [
['id' => 3, 'sort_order' => 1],
['id' => 7, 'sort_order' => 2],
])->willReturn(true);
$result = $this->service->reorderFromItems(1, 'bookmarks', [
['id' => 3, 'sort_order' => 1],
['id' => 7, 'sort_order' => 2],
]);
$this->assertTrue($result['ok']);
}
public function testReorderRootFromItemsHappyPath(): void
{
$this->navigationRepo->expects($this->once())->method('updateRootSortOrders')->with(1, [
['kind' => 'group', 'id' => 2, 'sort_order' => 1],
['kind' => 'bookmark', 'id' => 5, 'sort_order' => 2],
])->willReturn(true);
$result = $this->service->reorderRootFromItems(1, [
['kind' => 'group', 'id' => 2, 'sort_order' => 1],
['kind' => 'bookmark', 'id' => 5, 'sort_order' => 2],
]);
$this->assertTrue($result['ok']);
}
public function testReorderRootFromItemsRejectsEmptyItems(): void
{
$result = $this->service->reorderRootFromItems(1, []);
$this->assertFalse($result['ok']);
$this->assertSame('invalid_items', $result['error']);
}
public function testAllowedGroupIconsReturnsNonEmptyList(): void
{
$icons = BookmarkService::allowedGroupIcons();
$this->assertNotEmpty($icons);
$this->assertContains('bi-folder', $icons);
$this->assertContains('bi-house', $icons);
}
}

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

@@ -0,0 +1,115 @@
/**
* app-bookmark-form.css
* Styles for bookmark and bookmark-group dialogs.
*/
@layer components {
dialog[data-app-bookmark-dialog] > article.app-bookmark-dialog,
dialog[data-app-bookmark-group-dialog] > article.app-bookmark-dialog {
max-width: 24rem;
}
dialog[data-app-bookmark-dialog] > article.app-bookmark-dialog > header,
dialog[data-app-bookmark-group-dialog] > article.app-bookmark-dialog > header {
position: relative;
padding-inline-end: 2rem;
}
dialog[data-app-bookmark-dialog] > article.app-bookmark-dialog > header h2,
dialog[data-app-bookmark-group-dialog] > article.app-bookmark-dialog > header h2 {
margin: 0;
}
dialog[data-app-bookmark-dialog] [data-bookmark-dialog-close].close,
dialog[data-app-bookmark-group-dialog] [data-bookmark-group-dialog-close].close {
position: absolute;
inset-inline-end: 1rem;
inset-block-start: 1rem;
float: none;
margin: 0;
}
dialog[data-app-bookmark-dialog] form,
dialog[data-app-bookmark-group-dialog] form {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
dialog[data-app-bookmark-dialog] form label,
dialog[data-app-bookmark-group-dialog] form label {
font-size: 0.85rem;
font-weight: 600;
margin: 0;
}
dialog[data-app-bookmark-dialog] form input[type="text"],
dialog[data-app-bookmark-dialog] form select,
dialog[data-app-bookmark-group-dialog] form input[type="text"],
dialog[data-app-bookmark-group-dialog] form select {
width: 100%;
margin: 0;
}
dialog[data-app-bookmark-dialog] form footer,
dialog[data-app-bookmark-group-dialog] form footer {
padding-top: var(--app-spacing);
}
.app-bookmark-delete-btn {
color: var(--app-del-color, #c62828);
border-color: var(--app-del-color, #c62828);
width: 100%;
}
.app-bookmark-delete-btn:hover {
background: var(--app-del-color, #c62828);
color: #fff;
}
.app-bookmark-new-group-btn {
width: 100%;
}
.app-bookmark-group-icon-picker {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 0.3rem;
}
.app-bookmark-group-icon-option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1 / 1;
min-height: 1.9rem;
border: 1px solid var(--app-border);
border-radius: var(--app-border-radius);
background: transparent;
color: var(--app-muted-color);
cursor: pointer;
padding: 0;
margin: 0;
transition: border-color 120ms ease, color 120ms ease;
}
.app-bookmark-group-icon-option:hover {
border-color: var(--app-primary);
color: var(--app-primary);
}
.app-bookmark-group-icon-option.app-bookmark-icon-active {
border-color: var(--app-primary);
background: var(--app-primary);
color: #fff;
}
.app-bookmark-group-icon-option > i {
font-size: 0.95rem;
}
.app-bookmark-delete-separator {
margin-block: 0.2rem 0.35rem;
}
}

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

@@ -0,0 +1,529 @@
/**
* Sidebar bookmarks panel actions:
* - Reorder root navigation (groups + ungrouped bookmarks) via up/down
* - Reorder bookmarks inside groups (up/down)
* - Edit/Delete groups and bookmarks with confirm dialog
*/
import { showAsyncFlash } from '../../../../js/components/app-async-flash.js';
import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js';
import { confirmDialog } from '../../../../js/core/app-confirm-dialog.js';
export function initBookmarkPanel(root = document, options = {}) {
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)) {
return { destroy: () => {} };
}
if (panel.dataset.bookmarkPanelBound === '1' && panel._bookmarkPanelApi) {
return panel._bookmarkPanelApi;
}
const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim();
const groupDeleteUrl = String(panel.dataset.bookmarkGroupDeleteUrl || '').trim();
const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim();
if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) {
warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' });
return { destroy: () => {} };
}
const documentRoot = document.documentElement;
const csrfKey = String(documentRoot.dataset.csrfKey || '').trim();
const csrfToken = String(documentRoot.dataset.csrfToken || '').trim();
if (!csrfKey || !csrfToken) {
warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' });
return { destroy: () => {} };
}
const abortController = new AbortController();
const { signal } = abortController;
const timerIds = [];
const messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?';
const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted';
const messageGroupDeleteFailed = panel.dataset.bookmarkMsgGroupDeleteFailed || 'Group delete failed';
const messageBookmarkDeleteConfirm = panel.dataset.bookmarkMsgBookmarkDeleteConfirm || 'Delete this bookmark?';
const messageBookmarkDeleted = panel.dataset.bookmarkMsgBookmarkDeleted || 'Bookmark deleted';
const messageBookmarkDeleteFailed = panel.dataset.bookmarkMsgBookmarkDeleteFailed || 'Bookmark action failed';
const messageGroupActionFailed = panel.dataset.bookmarkMsgGroupActionFailed || messageGroupDeleteFailed;
const messageBookmarkActionFailed = panel.dataset.bookmarkMsgBookmarkActionFailed || messageBookmarkDeleteFailed;
const messageReorderFailed = panel.dataset.bookmarkMsgReorderFailed || 'Reorder failed';
const labelDelete = panel.dataset.bookmarkLabelDelete || 'Delete';
const moduleContext = { module: 'bookmark-panel' };
panel.querySelectorAll('[data-bookmark-item-action], [data-bookmark-group-action]').forEach((button) => {
if (!(button instanceof HTMLButtonElement)) {
return;
}
button.dataset.baseDisabled = button.disabled ? '1' : '0';
});
let pending = false;
const actionMenus = () => Array.from(panel.querySelectorAll('[data-bookmark-action-menu]'))
.filter((menu) => menu instanceof HTMLDetailsElement);
const closeActionMenus = (except = null) => {
actionMenus().forEach((menu) => {
if (menu === except) {
return;
}
menu.open = false;
});
};
const panelActionButtons = () => Array.from(
panel.querySelectorAll('[data-bookmark-item-action], [data-bookmark-group-action]')
).filter((button) => button instanceof HTMLButtonElement);
const setPending = (nextPending) => {
pending = nextPending;
panelActionButtons().forEach((button) => {
const baselineDisabled = button.dataset.baseDisabled === '1';
button.disabled = nextPending || baselineDisabled;
});
};
const parseJsonSafe = async (response) => {
try {
return await response.json();
} catch {
return null;
}
};
const reportError = (code, message, details = {}) => {
warnOnce(code, message, { ...moduleContext, ...details });
showAsyncFlash('error', message);
};
const runReload = (successMessage = '') => {
if (String(successMessage).trim() !== '') {
showAsyncFlash('success', successMessage, 2500);
}
const timerId = window.setTimeout(() => window.location.reload(), 450);
timerIds.push(timerId);
};
const directChildren = (container, className) => {
if (!(container instanceof HTMLElement)) {
return [];
}
return Array.from(container.children).filter(
(child) => child instanceof HTMLElement && child.classList.contains(className)
);
};
const swapInList = (items, currentItem, direction) => {
const currentIndex = items.indexOf(currentItem);
if (currentIndex < 0) {
return [];
}
const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (nextIndex < 0 || nextIndex >= items.length) {
return [];
}
const reordered = items.slice();
const temp = reordered[currentIndex];
reordered[currentIndex] = reordered[nextIndex];
reordered[nextIndex] = temp;
return reordered;
};
const buildPairs = (items, dataKey) => {
const pairs = [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const id = Number.parseInt(item.dataset[dataKey] || '', 10);
if (id <= 0) {
return [];
}
pairs.push({
id,
sort_order: index + 1
});
}
return pairs;
};
const buildRootPairs = (items) => {
const pairs = [];
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const kind = String(item.dataset.bookmarkRootKind || '').trim();
const id = Number.parseInt(item.dataset.bookmarkRootId || '', 10);
if ((kind !== 'group' && kind !== 'bookmark') || id <= 0) {
return [];
}
pairs.push({
kind,
id,
sort_order: index + 1
});
}
return pairs;
};
const submitReorder = async (type, pairs) => {
if (pairs.length === 0) {
return;
}
const body = new FormData();
body.set(csrfKey, csrfToken);
body.set('type', type);
body.set('items', JSON.stringify(pairs));
setPending(true);
try {
const response = await fetch(reorderUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body
});
const json = await parseJsonSafe(response);
if (!response.ok || !json) {
reportError('FETCH_FAILED', messageReorderFailed, { status: response.status, type });
return;
}
if (!json.ok) {
reportError('FETCH_FAILED', messageReorderFailed, { error: json.error, type });
return;
}
runReload();
} catch (error) {
reportError('FETCH_ERROR', messageReorderFailed, { error, type });
} finally {
setPending(false);
}
};
const reorderGroup = async (button) => {
const groupItem = button.closest('.app-sidebar-bookmark-root-item.app-sidebar-bookmark-group');
if (!(groupItem instanceof HTMLElement)) {
return;
}
const container = groupItem.parentElement;
const rootItems = directChildren(container, 'app-sidebar-bookmark-root-item');
if (rootItems.length < 2) {
return;
}
const action = String(button.dataset.bookmarkGroupAction || '').trim();
const direction = action === 'move-up' ? 'up' : action === 'move-down' ? 'down' : '';
if (!direction) {
return;
}
const reorderedRootItems = swapInList(rootItems, groupItem, direction);
if (reorderedRootItems.length === 0) {
return;
}
const pairs = buildRootPairs(reorderedRootItems);
if (pairs.length !== reorderedRootItems.length) {
reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'root' });
return;
}
await submitReorder('root', pairs);
};
const reorderBookmark = async (button) => {
const bookmarkItem = button.closest('.app-sidebar-bookmark-item');
if (!(bookmarkItem instanceof HTMLElement)) {
return;
}
const action = String(button.dataset.bookmarkItemAction || '').trim();
const direction = action === 'move-up' ? 'up' : action === 'move-down' ? 'down' : '';
if (!direction) {
return;
}
const scope = String(button.dataset.bookmarkItemActionScope || '').trim();
if (scope === 'root') {
const rootItem = bookmarkItem.closest('.app-sidebar-bookmark-root-item');
if (!(rootItem instanceof HTMLElement)) {
return;
}
const container = rootItem.parentElement;
const rootItems = directChildren(container, 'app-sidebar-bookmark-root-item');
if (rootItems.length < 2) {
return;
}
const reorderedRootItems = swapInList(rootItems, rootItem, direction);
if (reorderedRootItems.length === 0) {
return;
}
const rootPairs = buildRootPairs(reorderedRootItems);
if (rootPairs.length !== reorderedRootItems.length) {
reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'root' });
return;
}
await submitReorder('root', rootPairs);
return;
}
const container = bookmarkItem.parentElement;
const bookmarks = directChildren(container, 'app-sidebar-bookmark-item');
if (bookmarks.length < 2) {
return;
}
const reorderedBookmarks = swapInList(bookmarks, bookmarkItem, direction);
if (reorderedBookmarks.length === 0) {
return;
}
const pairs = buildPairs(reorderedBookmarks, 'bookmarkId');
if (pairs.length !== reorderedBookmarks.length) {
reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'bookmarks' });
return;
}
await submitReorder('bookmarks', pairs);
};
const deleteGroup = async (button) => {
const groupItem = button.closest('.app-sidebar-bookmark-group');
if (!(groupItem instanceof HTMLElement)) {
return;
}
const groupId = Number.parseInt(groupItem.dataset.bookmarkGroupId || '', 10);
if (groupId <= 0) {
reportError('UI_INVALID_STATE', messageGroupDeleteFailed, { reason: 'invalid_group_id' });
return;
}
const approved = await confirmDialog.confirm({
message: messageDeleteConfirm,
confirmLabel: labelDelete,
actionKind: 'delete',
variant: 'danger',
focus: 'cancel'
});
if (!approved) {
return;
}
const body = new FormData();
body.set(csrfKey, csrfToken);
body.set('id', String(groupId));
setPending(true);
try {
const response = await fetch(groupDeleteUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body
});
const json = await parseJsonSafe(response);
if (!response.ok || !json) {
reportError('FETCH_FAILED', messageGroupDeleteFailed, { status: response.status, groupId });
return;
}
if (!json.ok) {
reportError('FETCH_FAILED', messageGroupDeleteFailed, { error: json.error, groupId });
return;
}
runReload(messageGroupDeleted);
} catch (error) {
reportError('FETCH_ERROR', messageGroupDeleteFailed, { error, groupId });
} finally {
setPending(false);
}
};
const deleteBookmark = async (button) => {
const bookmarkItem = button.closest('.app-sidebar-bookmark-item');
if (!(bookmarkItem instanceof HTMLElement)) {
return;
}
const bookmarkId = Number.parseInt(bookmarkItem.dataset.bookmarkId || '', 10);
if (bookmarkId <= 0) {
reportError('UI_INVALID_STATE', messageBookmarkDeleteFailed, { reason: 'invalid_bookmark_id' });
return;
}
const approved = await confirmDialog.confirm({
message: messageBookmarkDeleteConfirm,
confirmLabel: labelDelete,
actionKind: 'delete',
variant: 'danger',
focus: 'cancel'
});
if (!approved) {
return;
}
const body = new FormData();
body.set(csrfKey, csrfToken);
body.set('id', String(bookmarkId));
setPending(true);
try {
const response = await fetch(bookmarkDeleteUrl, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body
});
const json = await parseJsonSafe(response);
if (!response.ok || !json) {
reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { status: response.status, bookmarkId });
return;
}
if (!json.ok) {
reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { error: json.error, bookmarkId });
return;
}
runReload(messageBookmarkDeleted);
} catch (error) {
reportError('FETCH_ERROR', messageBookmarkDeleteFailed, { error, bookmarkId });
} finally {
setPending(false);
}
};
const editGroup = (button) => {
const groupItem = button.closest('.app-sidebar-bookmark-group');
if (!(groupItem instanceof HTMLElement)) {
return;
}
const groupId = Number.parseInt(groupItem.dataset.bookmarkGroupId || '', 10);
const groupName = String(groupItem.dataset.bookmarkGroupName || '').trim();
const groupIcon = String(groupItem.dataset.bookmarkGroupIcon || 'bi-folder').trim() || 'bi-folder';
if (groupId <= 0 || !groupName) {
reportError('UI_INVALID_STATE', messageGroupActionFailed, { reason: 'invalid_group_payload' });
return;
}
document.dispatchEvent(new CustomEvent('app:bookmark-group-edit', {
detail: {
id: groupId,
name: groupName,
icon: groupIcon
}
}));
};
const editBookmark = (button) => {
const bookmarkItem = button.closest('.app-sidebar-bookmark-item');
if (!(bookmarkItem instanceof HTMLElement)) {
return;
}
const bookmarkId = Number.parseInt(bookmarkItem.dataset.bookmarkId || '', 10);
const bookmarkName = String(bookmarkItem.dataset.bookmarkName || '').trim();
const groupId = Number.parseInt(bookmarkItem.dataset.bookmarkGroupId || '', 10);
if (bookmarkId <= 0 || !bookmarkName) {
reportError('UI_INVALID_STATE', messageBookmarkActionFailed, { reason: 'invalid_bookmark_payload' });
return;
}
document.dispatchEvent(new CustomEvent('app:bookmark-edit', {
detail: {
id: bookmarkId,
name: bookmarkName,
groupId: groupId > 0 ? String(groupId) : ''
}
}));
};
panel.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement ? event.target : null;
if (!target) {
return;
}
const menuSummary = target.closest('[data-bookmark-action-menu] > summary');
if (menuSummary instanceof HTMLElement) {
if (pending) {
event.preventDefault();
event.stopPropagation();
return;
}
const menu = menuSummary.parentElement;
if (menu instanceof HTMLDetailsElement) {
event.preventDefault();
event.stopPropagation();
const shouldOpen = !menu.open;
closeActionMenus(menu);
menu.open = shouldOpen;
}
return;
}
if (pending) {
return;
}
const groupActionButton = target.closest('[data-bookmark-group-action]');
if (groupActionButton instanceof HTMLButtonElement) {
event.preventDefault();
event.stopPropagation();
closeActionMenus();
const action = String(groupActionButton.dataset.bookmarkGroupAction || '').trim();
if (action === 'edit') {
editGroup(groupActionButton);
return;
}
if (action === 'delete') {
void deleteGroup(groupActionButton);
return;
}
if (action === 'move-up' || action === 'move-down') {
void reorderGroup(groupActionButton);
}
return;
}
const itemActionButton = target.closest('[data-bookmark-item-action]');
if (itemActionButton instanceof HTMLButtonElement) {
event.preventDefault();
event.stopPropagation();
closeActionMenus();
const action = String(itemActionButton.dataset.bookmarkItemAction || '').trim();
if (action === 'edit') {
editBookmark(itemActionButton);
return;
}
if (action === 'delete') {
void deleteBookmark(itemActionButton);
return;
}
if (action === 'move-up' || action === 'move-down') {
void reorderBookmark(itemActionButton);
}
}
}, { signal });
document.addEventListener('click', (event) => {
if (!(event.target instanceof Node)) {
return;
}
const clickedInsideMenu = actionMenus().some((menu) => menu.contains(event.target));
if (!clickedInsideMenu) {
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

@@ -0,0 +1,632 @@
/**
* app-bookmark-save.js
* Bookmark dialog (create/update) + group dialog (create/update).
*/
import { showAsyncFlash } from '../../../../js/components/app-async-flash.js';
import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js';
import { getAppBase } from '../../../../js/pages/app-list-utils.js';
export function initBookmarkSave(root = document, options = {}) {
const host = resolveHost(root);
const openButtonSelector = String(options.openButtonSelector || '[data-bookmark-open-dialog]').trim() || '[data-bookmark-open-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.querySelector(bookmarkDialogSelector);
if (!(bookmarkDialog instanceof HTMLDialogElement)) {
return { destroy: () => {} };
}
if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) {
return bookmarkDialog._bookmarkSaveApi;
}
const bookmarkForm = bookmarkDialog.querySelector('[data-bookmark-save-form]');
if (!(bookmarkForm instanceof HTMLFormElement)) {
return { destroy: () => {} };
}
const groupDialog = host.querySelector(groupDialogSelector);
const groupForm = groupDialog instanceof HTMLDialogElement
? groupDialog.querySelector('[data-bookmark-group-save-form]')
: null;
const hasGroupDialog = groupDialog instanceof HTMLDialogElement && groupForm instanceof HTMLFormElement;
const abortController = new AbortController();
const { signal } = abortController;
const timerIds = [];
const documentRoot = document.documentElement;
const csrfKey = String(documentRoot.dataset.csrfKey || '').trim();
const csrfToken = String(documentRoot.dataset.csrfToken || '').trim();
const moduleContext = { module: 'bookmark-save' };
if (!csrfKey || !csrfToken) {
warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark dialog', moduleContext);
return { destroy: () => {} };
}
const nameInput = bookmarkForm.querySelector('input[name="name"]');
const groupSelect = bookmarkForm.querySelector('[data-bookmark-group-select]');
const newGroupBtn = bookmarkForm.querySelector('[data-bookmark-new-group]');
const closeBookmarkBtns = bookmarkDialog.querySelectorAll('[data-bookmark-dialog-close]');
const submitBtn = bookmarkDialog.querySelector('[data-bookmark-submit-label]');
const titleEl = bookmarkDialog.querySelector('#app-bookmark-dialog-title');
const maxBookmarkMessage = bookmarkDialog.dataset.maxMessage || '';
const bookmarkMessageSaved = bookmarkDialog.dataset.msgSaved || 'Bookmark saved';
const bookmarkMessageUpdated = bookmarkDialog.dataset.msgUpdated || 'Bookmark updated';
const bookmarkMessageErrorGeneric = bookmarkDialog.dataset.msgErrorGeneric || 'Bookmark action failed';
const bookmarkMessageNameRequired = bookmarkDialog.dataset.msgNameRequired || 'Bookmark name is required';
const bookmarkApiErrorMessages = {
name_required: bookmarkDialog.dataset.msgNameRequired || bookmarkMessageErrorGeneric,
url_required: bookmarkDialog.dataset.msgUrlRequired || bookmarkMessageErrorGeneric,
group_not_found: bookmarkDialog.dataset.msgGroupNotFound || bookmarkMessageErrorGeneric,
not_found: bookmarkMessageErrorGeneric,
create_failed: bookmarkMessageErrorGeneric,
update_failed: bookmarkMessageErrorGeneric
};
let editingBookmarkId = null;
let bookmarkReturnState = null;
let groupEditingId = null;
let groupMode = 'create';
let groupSource = 'sidebar';
const syncModalBodyClass = () => {
const isOpen = bookmarkDialog.open || (hasGroupDialog && groupDialog.open);
document.body.classList.toggle('modal-is-open', isOpen);
};
const reportError = (code, message, details = {}) => {
warnOnce(code, message, { ...moduleContext, ...details });
showAsyncFlash('error', message);
};
const parseJsonSafe = async (response) => {
try {
return await response.json();
} catch {
return null;
}
};
const resolveBookmarkApiErrorMessage = (errorCode) => {
if (!errorCode) return bookmarkMessageErrorGeneric;
return bookmarkApiErrorMessages[errorCode] || bookmarkMessageErrorGeneric;
};
const runReloadWithSuccess = (message) => {
showAsyncFlash('success', message, 2500);
const timerId = window.setTimeout(() => window.location.reload(), 450);
timerIds.push(timerId);
};
const closeBookmarkDialog = () => {
try {
if (bookmarkDialog.open) bookmarkDialog.close();
} catch {
// no-op
}
bookmarkForm.removeAttribute('aria-busy');
syncModalBodyClass();
};
const openBookmarkDialog = () => {
try {
bookmarkDialog.showModal();
syncModalBodyClass();
return true;
} catch (error) {
reportError('UI_DIALOG_OPEN_FAILED', bookmarkMessageErrorGeneric, { error });
return false;
}
};
const setBookmarkSubmitPending = (pending) => {
if (submitBtn instanceof HTMLButtonElement) {
submitBtn.disabled = pending;
}
bookmarkForm.setAttribute('aria-busy', pending ? 'true' : 'false');
};
const setBookmarkSubmitLabel = (label) => {
if (!(submitBtn instanceof HTMLElement)) return;
const text = String(label || '').trim();
if (!text) return;
submitBtn.textContent = text;
};
const applyBookmarkModeUi = (isEditMode) => {
if (titleEl) {
titleEl.textContent = isEditMode
? (bookmarkDialog.dataset.titleEdit || 'Bookmarks')
: (bookmarkDialog.dataset.titleCreate || 'Bookmarks');
}
setBookmarkSubmitLabel(isEditMode
? (bookmarkDialog.dataset.labelUpdate || 'Save')
: (bookmarkDialog.dataset.labelSave || 'Save'));
};
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');
applyBookmarkModeUi(isEditMode);
if (isEditMode) {
editingBookmarkId = openBtn.dataset.bookmarkExistingId || null;
if (nameInput) nameInput.value = openBtn.dataset.bookmarkExistingName || '';
if (groupSelect) groupSelect.value = openBtn.dataset.bookmarkExistingGroup || '';
return;
}
editingBookmarkId = null;
bookmarkForm.reset();
if (nameInput) {
const title = document.title.replace(/\s*[|–—].*$/, '').trim();
nameInput.value = title || '';
}
};
const openBookmarkEditFromSidebar = (detail) => {
const bookmarkId = Number.parseInt(String(detail?.id ?? ''), 10);
const bookmarkName = String(detail?.name ?? '').trim();
const groupId = String(detail?.groupId ?? '').trim();
if (bookmarkId <= 0 || !bookmarkName) {
reportError('UI_INVALID_STATE', bookmarkMessageErrorGeneric, { detail });
return;
}
editingBookmarkId = String(bookmarkId);
applyBookmarkModeUi(true);
if (nameInput instanceof HTMLInputElement) {
nameInput.value = bookmarkName;
}
if (groupSelect instanceof HTMLSelectElement) {
groupSelect.value = groupId;
}
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
nameInput.focus();
nameInput.select();
}
};
const captureBookmarkState = () => ({
isEditMode: editingBookmarkId !== null && editingBookmarkId !== '',
bookmarkId: editingBookmarkId,
name: nameInput instanceof HTMLInputElement ? nameInput.value : '',
groupId: groupSelect instanceof HTMLSelectElement ? groupSelect.value : ''
});
const restoreBookmarkFromState = (selectedGroupId = null) => {
const state = bookmarkReturnState;
bookmarkReturnState = null;
if (!state) {
return;
}
applyBookmarkModeUi(Boolean(state.isEditMode));
editingBookmarkId = state.isEditMode ? state.bookmarkId : null;
if (nameInput instanceof HTMLInputElement) {
nameInput.value = state.name || '';
}
if (groupSelect instanceof HTMLSelectElement) {
groupSelect.value = selectedGroupId ?? state.groupId ?? '';
}
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
nameInput.focus();
nameInput.select();
}
};
// Bookmark dialog open
if (openBtn instanceof HTMLButtonElement) {
openBtn.addEventListener('click', () => {
configureBookmarkModeFromTopbar();
if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) {
nameInput.select();
}
}, { signal });
}
document.addEventListener('app:bookmark-edit', (event) => {
const detail = event instanceof CustomEvent ? event.detail : null;
openBookmarkEditFromSidebar(detail);
}, { signal });
closeBookmarkBtns.forEach((btn) => {
btn.addEventListener('click', (event) => {
event.preventDefault();
closeBookmarkDialog();
}, { signal });
});
bookmarkDialog.addEventListener('click', (event) => {
if (event.target === bookmarkDialog) {
closeBookmarkDialog();
}
}, { signal });
bookmarkDialog.addEventListener('close', () => {
bookmarkForm.removeAttribute('aria-busy');
syncModalBodyClass();
}, { signal });
bookmarkDialog.addEventListener('cancel', () => {
bookmarkForm.removeAttribute('aria-busy');
syncModalBodyClass();
}, { signal });
// Save bookmark
bookmarkForm.addEventListener('submit', async (event) => {
event.preventDefault();
const name = nameInput instanceof HTMLInputElement ? nameInput.value.trim() : '';
if (!name) {
showAsyncFlash('warning', bookmarkMessageNameRequired);
return;
}
const body = new FormData();
body.set(csrfKey, csrfToken);
body.set('name', name);
body.set('group_id', groupSelect instanceof HTMLSelectElement ? groupSelect.value : '');
setBookmarkSubmitPending(true);
if (editingBookmarkId) {
body.set('id', editingBookmarkId);
try {
const response = await fetch(getAppBase() + 'bookmarks/update-data', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body
});
const json = await parseJsonSafe(response);
if (!response.ok || !json) {
reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status });
return;
}
if (!json.ok) {
reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error });
return;
}
closeBookmarkDialog();
runReloadWithSuccess(bookmarkMessageUpdated);
} catch (error) {
reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error });
} finally {
setBookmarkSubmitPending(false);
}
return;
}
body.set('url', currentRelativeUrl());
try {
const response = await fetch(getAppBase() + 'bookmarks/save-data', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body
});
const json = await parseJsonSafe(response);
if (!response.ok || !json) {
reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status });
return;
}
if (!json.ok) {
if (json.error === 'max_reached') {
showAsyncFlash('warning', maxBookmarkMessage || 'Maximum bookmarks reached');
return;
}
reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error });
return;
}
closeBookmarkDialog();
runReloadWithSuccess(json.mode === 'updated' ? bookmarkMessageUpdated : bookmarkMessageSaved);
} catch (error) {
reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error });
} finally {
setBookmarkSubmitPending(false);
}
}, { signal });
const destroy = () => {
abortController.abort();
timerIds.forEach((timerId) => window.clearTimeout(timerId));
timerIds.length = 0;
bookmarkForm.removeAttribute('aria-busy');
delete bookmarkDialog.dataset.bookmarkSaveBound;
delete bookmarkDialog._bookmarkSaveApi;
};
const api = { destroy };
bookmarkDialog.dataset.bookmarkSaveBound = '1';
bookmarkDialog._bookmarkSaveApi = api;
if (!hasGroupDialog) {
return api;
}
const groupNameInput = groupForm.querySelector('input[name="name"]');
const groupIconInput = groupForm.querySelector('input[name="icon"]');
const groupIconPicker = groupForm.querySelector('[data-bookmark-group-icon-picker]');
const groupCloseBtns = groupDialog.querySelectorAll('[data-bookmark-group-dialog-close]');
const groupTitleEl = groupDialog.querySelector('#app-bookmark-group-dialog-title');
const groupSubmitBtn = groupDialog.querySelector('[data-bookmark-group-submit-label]');
const groupMessageCreated = groupDialog.dataset.msgCreated || 'Group created';
const groupMessageUpdated = groupDialog.dataset.msgUpdated || 'Group updated';
const groupMessageErrorGeneric = groupDialog.dataset.msgErrorGeneric || 'Group action failed';
const groupMessageNameRequired = groupDialog.dataset.msgNameRequired || 'Group name is required';
const maxGroupMessage = groupDialog.dataset.maxMessage || 'Maximum groups reached';
const groupApiErrorMessages = {
name_required: groupMessageNameRequired,
not_found: groupMessageErrorGeneric,
create_failed: groupMessageErrorGeneric,
update_failed: groupMessageErrorGeneric
};
const resolveGroupApiErrorMessage = (errorCode) => {
if (!errorCode) return groupMessageErrorGeneric;
return groupApiErrorMessages[errorCode] || groupMessageErrorGeneric;
};
const closeGroupDialog = () => {
try {
if (groupDialog.open) groupDialog.close();
} catch {
// no-op
}
groupForm.removeAttribute('aria-busy');
syncModalBodyClass();
};
const openGroupDialog = () => {
try {
groupDialog.showModal();
syncModalBodyClass();
return true;
} catch (error) {
reportError('UI_DIALOG_OPEN_FAILED', groupMessageErrorGeneric, { error });
return false;
}
};
const setGroupSubmitPending = (pending) => {
if (groupSubmitBtn instanceof HTMLButtonElement) {
groupSubmitBtn.disabled = pending;
}
groupForm.setAttribute('aria-busy', pending ? 'true' : 'false');
};
const setGroupSubmitLabel = (label) => {
if (!(groupSubmitBtn instanceof HTMLElement)) return;
const text = String(label || '').trim();
if (!text) return;
groupSubmitBtn.textContent = text;
};
const setGroupIcon = (icon) => {
const value = String(icon || '').trim() || 'bi-folder';
if (groupIconInput instanceof HTMLInputElement) {
groupIconInput.value = value;
}
if (groupIconPicker instanceof HTMLElement) {
groupIconPicker.querySelectorAll('.app-bookmark-icon-active').forEach((item) => item.classList.remove('app-bookmark-icon-active'));
const selected = groupIconPicker.querySelector(`[data-icon="${value}"]`);
if (selected instanceof HTMLElement) {
selected.classList.add('app-bookmark-icon-active');
}
}
};
const upsertGroupOption = (group) => {
if (!(groupSelect instanceof HTMLSelectElement)) {
return;
}
const id = String(group?.id ?? '').trim();
const name = String(group?.name ?? '').trim();
if (!id || !name) {
return;
}
const existing = Array.from(groupSelect.options).find((option) => option.value === id);
if (existing) {
existing.textContent = name;
} else {
const option = document.createElement('option');
option.value = id;
option.textContent = name;
groupSelect.appendChild(option);
}
};
const configureGroupDialog = ({ mode, source, id = null, name = '', icon = 'bi-folder', restoreState = null }) => {
groupMode = mode === 'edit' ? 'edit' : 'create';
groupSource = source === 'bookmark' ? 'bookmark' : 'sidebar';
groupEditingId = groupMode === 'edit' ? id : null;
bookmarkReturnState = groupSource === 'bookmark' ? restoreState : null;
groupForm.reset();
setGroupIcon(icon);
if (groupNameInput instanceof HTMLInputElement) {
groupNameInput.value = String(name || '');
}
if (groupTitleEl instanceof HTMLElement) {
groupTitleEl.textContent = groupMode === 'edit'
? (groupDialog.dataset.titleEdit || 'Edit group')
: (groupDialog.dataset.titleCreate || 'New group');
}
setGroupSubmitLabel(groupMode === 'edit'
? (groupDialog.dataset.labelUpdate || 'Save')
: (groupDialog.dataset.labelSave || 'Save'));
};
const handleGroupCancel = () => {
closeGroupDialog();
if (groupSource === 'bookmark' && bookmarkReturnState) {
restoreBookmarkFromState();
} else {
bookmarkReturnState = null;
}
};
const openCreateGroupFromBookmarkDialog = () => {
const state = captureBookmarkState();
closeBookmarkDialog();
configureGroupDialog({
mode: 'create',
source: 'bookmark',
restoreState: state
});
if (openGroupDialog() && groupNameInput instanceof HTMLInputElement) {
groupNameInput.focus();
groupNameInput.select();
}
};
if (newGroupBtn instanceof HTMLButtonElement) {
newGroupBtn.addEventListener('click', (event) => {
event.preventDefault();
openCreateGroupFromBookmarkDialog();
}, { signal });
}
if (groupIconPicker instanceof HTMLElement) {
groupIconPicker.addEventListener('click', (event) => {
const target = event.target instanceof HTMLElement ? event.target.closest('[data-icon]') : null;
if (!(target instanceof HTMLElement)) return;
event.preventDefault();
setGroupIcon(target.dataset.icon || 'bi-folder');
}, { signal });
}
groupCloseBtns.forEach((btn) => {
btn.addEventListener('click', (event) => {
event.preventDefault();
handleGroupCancel();
}, { signal });
});
groupDialog.addEventListener('click', (event) => {
if (event.target === groupDialog) {
handleGroupCancel();
}
}, { signal });
groupDialog.addEventListener('cancel', (event) => {
event.preventDefault();
handleGroupCancel();
}, { signal });
groupDialog.addEventListener('close', () => {
groupForm.removeAttribute('aria-busy');
syncModalBodyClass();
}, { signal });
document.addEventListener('app:bookmark-group-edit', (event) => {
const detail = event instanceof CustomEvent ? event.detail : null;
const groupId = Number.parseInt(String(detail?.id ?? ''), 10);
const groupName = String(detail?.name ?? '').trim();
const groupIcon = String(detail?.icon ?? 'bi-folder').trim() || 'bi-folder';
if (groupId <= 0 || !groupName) {
reportError('UI_INVALID_STATE', groupMessageErrorGeneric, { detail });
return;
}
configureGroupDialog({
mode: 'edit',
source: 'sidebar',
id: groupId,
name: groupName,
icon: groupIcon
});
if (openGroupDialog() && groupNameInput instanceof HTMLInputElement) {
groupNameInput.focus();
groupNameInput.select();
}
}, { signal });
groupForm.addEventListener('submit', async (event) => {
event.preventDefault();
const name = groupNameInput instanceof HTMLInputElement ? groupNameInput.value.trim() : '';
if (!name) {
showAsyncFlash('warning', groupMessageNameRequired);
return;
}
const icon = groupIconInput instanceof HTMLInputElement ? groupIconInput.value : 'bi-folder';
const body = new FormData();
body.set(csrfKey, csrfToken);
body.set('name', name);
body.set('icon', icon);
if (groupMode === 'edit' && groupEditingId) {
body.set('id', String(groupEditingId));
}
setGroupSubmitPending(true);
try {
const response = await fetch(getAppBase() + 'bookmarks/group-save-data', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body
});
const json = await parseJsonSafe(response);
if (!response.ok || !json) {
reportError('FETCH_FAILED', groupMessageErrorGeneric, { status: response.status });
return;
}
if (!json.ok) {
if (json.error === 'max_reached') {
showAsyncFlash('warning', maxGroupMessage);
return;
}
reportError('FETCH_FAILED', resolveGroupApiErrorMessage(json.error), { error: json.error });
return;
}
if (!json.group) {
reportError('UI_INVALID_STATE', groupMessageErrorGeneric, { reason: 'missing_group' });
return;
}
const mode = json.mode === 'updated' ? 'updated' : 'created';
const successMessage = mode === 'updated' ? groupMessageUpdated : groupMessageCreated;
closeGroupDialog();
if (groupSource === 'bookmark') {
upsertGroupOption(json.group);
restoreBookmarkFromState(String(json.group.id));
showAsyncFlash('success', successMessage, 2000);
} else {
runReloadWithSuccess(successMessage);
}
} catch (error) {
reportError('FETCH_ERROR', groupMessageErrorGeneric, { error });
} finally {
setGroupSubmitPending(false);
}
}, { signal });
return api;
}
function currentRelativeUrl() {
const basePath = new URL(getAppBase()).pathname;
const full = window.location.pathname + window.location.search;
if (full.startsWith(basePath)) {
return full.slice(basePath.length);
}
return full.replace(/^\//, '');
}