forked from fa/breadcrumb-the-shire
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>
415 lines
14 KiB
PHP
415 lines
14 KiB
PHP
<?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;
|
|
}
|
|
|
|
}
|