mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor
- The Xtream detail guard hydrates the PWA session cache before judging an item on a cold navigation and fails closed when the catalog cannot place the item. - The dedicated Stalker search route filters withheld genres, re-fires on lock changes, judges paging on the raw page and restarts from page 1 on a lock flip. - The Xtream category dialog loads its lock candidates through the capability-selected data source; the PWA source now lists its raw categories with lock flags, so locks can be configured there too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4a1f8ba03
commit
7bd945e4fb
11 files changed
+239
-30
No files matched your search
@@ -118,9 +118,18 @@ locked default.
|
||||
by URL — bookmark, typed address, stale link — and redirect to the section
|
||||
root on refusal. Detail routes also resolve the ITEM's own category through
|
||||
`getContentByXtreamId`, so a locked movie paired with an unlocked category
|
||||
id in the URL is still refused. Electron routes carry SQLite row ids, so
|
||||
id in the URL is still refused; on a cold PWA navigation the guard
|
||||
hydrates the session cache first (`getPlaylist` + `getContent`), and an
|
||||
item the catalog cannot place fails closed (PIN prompt) rather than
|
||||
passing as unlocked. Electron routes carry SQLite row ids, so
|
||||
the Xtream guard maps them through the unfiltered category read; Stalker
|
||||
routes already carry the genre id.
|
||||
- **Stalker search route** (`/workspace/stalker/:id/search`, no category
|
||||
in the URL): `StalkerSearchComponent` filters each portal page through the
|
||||
same withheld-genre predicate, carries `parentalLockVersion` in its
|
||||
resource params, judges paging progress on the raw page (a page made only
|
||||
of locked rows is not the end of the results) and, on a lock flip past
|
||||
page 1, drops the withheld rows and restarts from page 1.
|
||||
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
|
||||
them, `getAllCategoriesForSelectedType` is the raw list for the lock
|
||||
dialog. `itvFullChannelList` and the content loader drop rows whose
|
||||
@@ -153,7 +162,11 @@ locked default.
|
||||
`StalkerCategoryLockDialogComponent` reached from a lock button above the
|
||||
categories rail; it offers "Lock adult (18+)" for genres the portal flags
|
||||
`censored`. All three list the locked names and can rewrite the locks, so
|
||||
each opens only after `requestUnlock()` succeeds.
|
||||
each opens only after `requestUnlock()` succeeds. The Xtream dialog loads
|
||||
its candidates through the capability-selected data source
|
||||
(`IXtreamDataSource.getAllCategories`, which the PWA source answers from
|
||||
its session cache or the API), so PWA users can set locks too; the
|
||||
hide/show checkboxes remain Electron-only.
|
||||
- Header lock/unlock button and the `parental-lock-now` /
|
||||
`parental-unlock` palette commands.
|
||||
|
||||
|
||||
@@ -403,6 +403,21 @@ describe('StalkerSearchComponent result paging', () => {
|
||||
expect(component.searchHasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps paging past a page whose rows were all withheld by the parental lock', () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
// The portal sent rows, every one of them locked: no visible growth,
|
||||
// but not the end of the results either.
|
||||
component.applySearchPageSuccess(2, [], 10, true);
|
||||
expect(component.searchResults()).toHaveLength(3);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
// An actually empty page still ends it.
|
||||
component.applySearchPageSuccess(3, [], 10, false);
|
||||
expect(component.searchHasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('stops paging when a total-backed append makes no progress', () => {
|
||||
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
|
||||
expect(component.searchHasMore()).toBe(true);
|
||||
|
||||
@@ -21,9 +21,11 @@ import {
|
||||
executeStalkerRequest,
|
||||
StalkerPortalRepairService,
|
||||
StalkerSessionService,
|
||||
withoutWithheldStalkerItems,
|
||||
} from '@iptvnator/portal/stalker/data-access';
|
||||
import {
|
||||
DataService,
|
||||
ParentalLockService,
|
||||
PlaylistsService,
|
||||
resetHostConnectivityGuard,
|
||||
} from '@iptvnator/services';
|
||||
@@ -120,6 +122,9 @@ export class StalkerSearchComponent {
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly location = inject(Location);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
/** Lock version the accumulated results were built under. */
|
||||
private searchResultsLockVersion: number | null = null;
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly playlistService = inject(PlaylistsService);
|
||||
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
@@ -241,6 +246,9 @@ export class StalkerSearchComponent {
|
||||
page: this.searchPage(),
|
||||
playlistId: this.currentPlaylist()?._id ?? null,
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
// Lock/unlock re-fires the search: withheld rows are dropped at
|
||||
// page time, so the results must be rebuilt when they change.
|
||||
parentalLockVersion: this.parentalLock.version(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
if (params.search.length < 3) {
|
||||
@@ -260,6 +268,36 @@ export class StalkerSearchComponent {
|
||||
return [];
|
||||
}
|
||||
const contentType = params.contentType;
|
||||
// The dedicated search route has no category guard, so it filters
|
||||
// the portal's rows itself: a locked genre's title must not reach
|
||||
// the grid, its detail or playback through search.
|
||||
const withheldCategoryIds = this.parentalLock.active()
|
||||
? new Set(
|
||||
this.parentalLock.lockedStalkerIds(
|
||||
playlist._id,
|
||||
contentType
|
||||
)
|
||||
)
|
||||
: new Set<string>();
|
||||
if (
|
||||
this.searchResultsLockVersion !== null &&
|
||||
this.searchResultsLockVersion !== params.parentalLockVersion &&
|
||||
params.page > 1
|
||||
) {
|
||||
// A lock flip past page 1: drop the withheld rows on screen
|
||||
// and rebuild from page 1 rather than appending to pages
|
||||
// accumulated under the old lock state.
|
||||
this.searchResultsLockVersion = params.parentalLockVersion;
|
||||
const retained = withoutWithheldStalkerItems(
|
||||
this.accumulatedSearchResults(),
|
||||
contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
this.accumulatedSearchResults.set(retained);
|
||||
this.searchPage.set(1);
|
||||
return retained;
|
||||
}
|
||||
this.searchResultsLockVersion = params.parentalLockVersion;
|
||||
|
||||
// Mirror the catalog request shape: many Ministra portals
|
||||
// return an empty list for get_ordered_list without the
|
||||
@@ -301,10 +339,15 @@ export class StalkerSearchComponent {
|
||||
playlist,
|
||||
requestParams
|
||||
);
|
||||
const items = (response.js?.data || []).map(
|
||||
const rawItems = (response.js?.data || []).map(
|
||||
(item: StalkerVodSource) =>
|
||||
this.processItemUrls(item, portalUrl)
|
||||
);
|
||||
const items = withoutWithheldStalkerItems(
|
||||
rawItems,
|
||||
contentType,
|
||||
withheldCategoryIds
|
||||
);
|
||||
|
||||
if (!isCurrent()) {
|
||||
return items;
|
||||
@@ -313,7 +356,10 @@ export class StalkerSearchComponent {
|
||||
return this.applySearchPageSuccess(
|
||||
params.page,
|
||||
items,
|
||||
response.js?.total_items
|
||||
response.js?.total_items,
|
||||
// A page made only of withheld rows still is a page the
|
||||
// portal served; judge progress on what it sent.
|
||||
rawItems.length > 0
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Stalker search page failed', {
|
||||
@@ -343,7 +389,8 @@ export class StalkerSearchComponent {
|
||||
applySearchPageSuccess(
|
||||
page: number,
|
||||
items: StalkerVodSource[],
|
||||
totalItems: number | undefined
|
||||
totalItems: number | undefined,
|
||||
pageHadRows: boolean = items.length > 0
|
||||
): StalkerVodSource[] {
|
||||
const previous = page === 1 ? [] : this.accumulatedSearchResults();
|
||||
const merged =
|
||||
@@ -352,13 +399,16 @@ export class StalkerSearchComponent {
|
||||
// a reported total. Dedup after mid-list portal mutations can leave
|
||||
// the unique list permanently shorter than total_items, and a
|
||||
// repeated page dedupes to no growth; either way a no-progress
|
||||
// append is the practical end of the results.
|
||||
const madeProgress = page === 1 || merged.length > previous.length;
|
||||
// append is the practical end of the results. A page whose rows were
|
||||
// all withheld by the parental lock counts as progress too.
|
||||
const withheldRows = pageHadRows && items.length === 0;
|
||||
const madeProgress =
|
||||
page === 1 || merged.length > previous.length || withheldRows;
|
||||
this.searchHasMore.set(
|
||||
madeProgress &&
|
||||
(typeof totalItems === 'number' && totalItems >= 0
|
||||
? merged.length < totalItems
|
||||
: items.length > 0)
|
||||
: pageHadRows)
|
||||
);
|
||||
this.searchAppendError.set(false);
|
||||
this.accumulatedSearchResults.set(merged);
|
||||
|
||||
+1
@@ -21,6 +21,7 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
|
||||
type: 'live' as const,
|
||||
xtream_id: 10,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
};
|
||||
const dbContentItem = {
|
||||
id: 1,
|
||||
|
||||
@@ -404,14 +404,54 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw category list in the SQLite wire shape, locked ones included:
|
||||
* the lock editor's candidates. Hidden/shown is not tracked in the PWA,
|
||||
* so every row reports `hidden: false`. Reads the session cache and
|
||||
* falls back to the API with the stored credentials on a cold session.
|
||||
*/
|
||||
async getAllCategories(
|
||||
playlistId: string,
|
||||
type: DbCategoryType
|
||||
): Promise<XtreamCategoryFromDb[]> {
|
||||
void playlistId;
|
||||
void type;
|
||||
// PWA doesn't track hidden categories - return empty
|
||||
return [];
|
||||
const categoryType: CategoryType = type === 'movies' ? 'vod' : type;
|
||||
const cacheKey = `${playlistId}-${categoryType}-categories`;
|
||||
let categories = this.categoryCache.get(cacheKey);
|
||||
if (!categories) {
|
||||
const playlist = await this.getPlaylist(playlistId);
|
||||
if (!playlist) {
|
||||
return [];
|
||||
}
|
||||
categories = await this.apiService.getCategories(
|
||||
{
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
categoryType
|
||||
);
|
||||
this.categoryCache.set(cacheKey, categories);
|
||||
}
|
||||
const locked = new Set(
|
||||
this.parentalLock.lockedXtreamIds(playlistId, type)
|
||||
);
|
||||
const rows: XtreamCategoryFromDb[] = [];
|
||||
for (const category of categories) {
|
||||
const xtreamId = Number(category.category_id);
|
||||
if (!Number.isFinite(xtreamId)) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
id: xtreamId,
|
||||
name: category.category_name,
|
||||
playlist_id: playlistId,
|
||||
type,
|
||||
xtream_id: xtreamId,
|
||||
hidden: false,
|
||||
locked: locked.has(xtreamId),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getCachedCategories(
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface XtreamCategoryFromDb {
|
||||
type: 'movies' | 'live' | 'series';
|
||||
xtream_id: number;
|
||||
hidden: boolean;
|
||||
/** Parental lock index; see ParentalLockService. */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+9
-7
@@ -80,13 +80,15 @@
|
||||
[attr.data-category-id]="category.xtream_id"
|
||||
(click)="toggleCategory(category)"
|
||||
>
|
||||
<mat-checkbox
|
||||
[checked]="category.selected"
|
||||
[aria-label]="category.name"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="toggleCategory(category)"
|
||||
>
|
||||
</mat-checkbox>
|
||||
@if (supportsVisibility) {
|
||||
<mat-checkbox
|
||||
[checked]="category.selected"
|
||||
[aria-label]="category.name"
|
||||
(click)="$event.stopPropagation()"
|
||||
(change)="toggleCategory(category)"
|
||||
>
|
||||
</mat-checkbox>
|
||||
}
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
<span class="item-count"
|
||||
>({{ getItemCount(category) }})</span
|
||||
|
||||
+17
-4
@@ -1,7 +1,12 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DatabaseService, XCategoryFromDb } from '@iptvnator/services';
|
||||
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
DatabaseService,
|
||||
RuntimeCapabilitiesService,
|
||||
XCategoryFromDb,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
@@ -17,15 +22,18 @@ const categories: XCategoryFromDb[] = [
|
||||
...category,
|
||||
playlist_id: 'mock-playlist',
|
||||
type: 'live',
|
||||
locked: false,
|
||||
}));
|
||||
|
||||
describe('CategoryManagementDialogComponent', () => {
|
||||
let fixture: ComponentFixture<CategoryManagementDialogComponent>;
|
||||
let component: CategoryManagementDialogComponent;
|
||||
const db = {
|
||||
getAllXtreamCategories: jest.fn(),
|
||||
updateCategoryVisibility: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
getAllCategories: jest.fn(),
|
||||
};
|
||||
const dialogRef = { close: jest.fn() };
|
||||
const data: CategoryManagementDialogData = {
|
||||
playlistId: 'mock-playlist',
|
||||
@@ -35,7 +43,7 @@ describe('CategoryManagementDialogComponent', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
db.getAllXtreamCategories.mockResolvedValue(categories);
|
||||
dataSource.getAllCategories.mockResolvedValue(categories);
|
||||
db.updateCategoryVisibility.mockResolvedValue(undefined);
|
||||
data.contentType = 'live';
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -45,6 +53,11 @@ describe('CategoryManagementDialogComponent', () => {
|
||||
],
|
||||
providers: [
|
||||
{ provide: DatabaseService, useValue: db },
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsXtreamSqliteDataSource: true },
|
||||
},
|
||||
{ provide: MatDialogRef, useValue: dialogRef },
|
||||
{ provide: MAT_DIALOG_DATA, useValue: data },
|
||||
],
|
||||
@@ -194,7 +207,7 @@ describe('CategoryManagementDialogComponent', () => {
|
||||
async (contentType, dbType) => {
|
||||
data.contentType = contentType;
|
||||
await component.ngOnInit();
|
||||
expect(db.getAllXtreamCategories).toHaveBeenLastCalledWith(
|
||||
expect(dataSource.getAllCategories).toHaveBeenLastCalledWith(
|
||||
'mock-playlist',
|
||||
dbType
|
||||
);
|
||||
|
||||
+12
-3
@@ -18,9 +18,11 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
DatabaseService,
|
||||
ParentalLockService,
|
||||
RuntimeCapabilitiesService,
|
||||
XCategoryFromDb,
|
||||
} from '@iptvnator/services';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
@@ -55,7 +57,14 @@ interface CategoryWithSelection extends XCategoryFromDb {
|
||||
})
|
||||
export class CategoryManagementDialogComponent implements OnInit {
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
/**
|
||||
* Hide/show is SQLite-backed and Electron-only; the PWA data source lists
|
||||
* its raw categories so the lock toggles still have candidates there.
|
||||
*/
|
||||
readonly supportsVisibility = this.runtime.supportsXtreamSqliteDataSource;
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly dialogRef = inject(
|
||||
MatDialogRef<CategoryManagementDialogComponent>
|
||||
@@ -132,7 +141,7 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
const expectedCategoryCount = this.data.itemCounts.size;
|
||||
|
||||
while (true) {
|
||||
const categories = await this.dbService.getAllXtreamCategories(
|
||||
const categories = await this.dataSource.getAllCategories(
|
||||
this.data.playlistId,
|
||||
type
|
||||
);
|
||||
@@ -212,10 +221,10 @@ export class CategoryManagementDialogComponent implements OnInit {
|
||||
.filter((c) => c.selected)
|
||||
.map((c) => c.id);
|
||||
|
||||
if (toHide.length > 0) {
|
||||
if (this.supportsVisibility && toHide.length > 0) {
|
||||
await this.dbService.updateCategoryVisibility(toHide, true);
|
||||
}
|
||||
if (toShow.length > 0) {
|
||||
if (this.supportsVisibility && toShow.length > 0) {
|
||||
await this.dbService.updateCategoryVisibility(toShow, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,11 @@ describe('parentalLockXtreamCategoryGuard', () => {
|
||||
let databaseService: { getAllXtreamCategories: jest.Mock };
|
||||
let runtime: { supportsXtreamSqliteDataSource: boolean };
|
||||
let router: { createUrlTree: jest.Mock };
|
||||
let dataSource: { getContentByXtreamId: jest.Mock };
|
||||
let dataSource: {
|
||||
getContentByXtreamId: jest.Mock;
|
||||
getPlaylist: jest.Mock;
|
||||
getContent: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
parentalLock = {
|
||||
@@ -61,6 +65,12 @@ describe('parentalLockXtreamCategoryGuard', () => {
|
||||
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
|
||||
dataSource = {
|
||||
getContentByXtreamId: jest.fn().mockResolvedValue(null),
|
||||
getPlaylist: jest.fn().mockResolvedValue({
|
||||
serverUrl: 'http://panel.example',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
}),
|
||||
getContent: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -152,6 +162,36 @@ describe('parentalLockXtreamCategoryGuard', () => {
|
||||
expect(router.createUrlTree).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrates the PWA session cache before judging a detail item on a cold navigation', async () => {
|
||||
runtime.supportsXtreamSqliteDataSource = false;
|
||||
parentalLock.isXtreamCategoryLocked.mockImplementation(
|
||||
(_playlist: string, _type: string, xtreamId: number) =>
|
||||
xtreamId === 900
|
||||
);
|
||||
dataSource.getContentByXtreamId
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ category_id: '900' });
|
||||
|
||||
const result = await run('vod', '901', { vodId: '555' });
|
||||
|
||||
expect(dataSource.getContent).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
{ serverUrl: 'http://panel.example', username: 'u', password: 'p' },
|
||||
'movie'
|
||||
);
|
||||
expect(parentalLock.requestUnlock).toHaveBeenCalled();
|
||||
expect(result).not.toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed for a detail item the catalog cannot place', async () => {
|
||||
dataSource.getContentByXtreamId.mockResolvedValue(null);
|
||||
|
||||
const result = await run('series', '13', { serialId: '777' });
|
||||
|
||||
expect(parentalLock.requestUnlock).toHaveBeenCalled();
|
||||
expect(result).not.toBe(true);
|
||||
});
|
||||
|
||||
it('uses the route id as the provider id in the PWA', async () => {
|
||||
runtime.supportsXtreamSqliteDataSource = false;
|
||||
|
||||
|
||||
@@ -99,14 +99,38 @@ export function parentalLockXtreamCategoryGuard(
|
||||
route.paramMap.get('vodId') ?? route.paramMap.get('serialId')
|
||||
);
|
||||
if (!locked && section !== 'live' && Number.isFinite(itemId)) {
|
||||
const item = await dataSource.getContentByXtreamId(
|
||||
const contentType = section === 'vod' ? 'movie' : 'series';
|
||||
let item = await dataSource.getContentByXtreamId(
|
||||
itemId,
|
||||
playlistId,
|
||||
section === 'vod' ? 'movie' : 'series'
|
||||
contentType
|
||||
);
|
||||
if (!item && rows === null) {
|
||||
// PWA: the session cache is empty on a cold navigation, so
|
||||
// hydrate it the way the route session would before judging.
|
||||
const playlist = await dataSource.getPlaylist(playlistId);
|
||||
if (playlist) {
|
||||
await dataSource.getContent(
|
||||
playlistId,
|
||||
{
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
},
|
||||
contentType
|
||||
);
|
||||
item = await dataSource.getContentByXtreamId(
|
||||
itemId,
|
||||
playlistId,
|
||||
contentType
|
||||
);
|
||||
}
|
||||
}
|
||||
const itemCategoryId = Number(item?.category_id);
|
||||
// An item the catalog cannot place fails closed: while the lock
|
||||
// is active an unknown title is not proof of an unlocked one.
|
||||
locked =
|
||||
Number.isFinite(itemCategoryId) && isLocked(itemCategoryId);
|
||||
!Number.isFinite(itemCategoryId) || isLocked(itemCategoryId);
|
||||
}
|
||||
|
||||
if (!locked || (await parentalLock.requestUnlock())) {
|
||||
|
||||
Reference in new issue
Block a user