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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user