Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: 'workspace-shell-util',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/workspace/shell/util',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
moduleFileExtensions: ['ts', 'js', 'html'],
};
+13
View File
@@ -0,0 +1,13 @@
{
"name": "workspace-shell-util",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/workspace/shell/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:workspace", "domain:workspace", "type:util"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './lib/settings-context.service';
export * from './lib/navigation/workspace-shell-route.utils';
export * from './lib/navigation/workspace-search-capability';
export * from './lib/workspace-shell-actions';
export * from './lib/workspace-startup-preferences.service';
@@ -0,0 +1,25 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellSearchMode,
} from './workspace-shell-route.utils';
export type WorkspaceSearchBehavior =
| 'disabled'
| 'local-filter'
| 'remote-search'
| 'advanced-only'
| 'degraded-loaded-only';
export interface WorkspaceSearchCapability {
enabled: boolean;
behavior: WorkspaceSearchBehavior;
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
searchMode: WorkspaceShellSearchMode;
placeholderKey: string;
scopeLabel: string;
statusLabel: string;
minLength: number;
advancedRouteTarget: readonly string[] | null;
}
@@ -0,0 +1,181 @@
import {
parseWorkspaceShellRoute,
usesWorkspaceRouteQuerySearch,
} from './workspace-shell-route.utils';
describe('workspace-shell-route.utils', () => {
it('parses top-level workspace pages', () => {
expect(parseWorkspaceShellRoute('/workspace')).toEqual(
expect.objectContaining({
kind: 'dashboard',
context: null,
contextPanel: 'none',
searchMode: 'advanced-only',
usesQuerySearch: false,
})
);
expect(parseWorkspaceShellRoute('/workspace/sources?q=matrix')).toEqual(
expect.objectContaining({
kind: 'sources',
context: null,
contextPanel: 'sources',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/settings')).toEqual(
expect.objectContaining({
kind: 'settings',
context: null,
contextPanel: 'settings',
})
);
expect(parseWorkspaceShellRoute('/workspace/global-favorites')).toEqual(
expect.objectContaining({
kind: 'global-favorites',
})
);
expect(parseWorkspaceShellRoute('/workspace/global-recent')).toEqual(
expect.objectContaining({
kind: 'global-recent',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
expect(parseWorkspaceShellRoute('/workspace/downloads')).toEqual(
expect.objectContaining({
kind: 'downloads',
})
);
});
it('parses Xtream portal routes and detects context-panel/query-search state', () => {
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/vod/42')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'xtreams',
playlistId: 'pl-1',
},
section: 'vod',
contextPanel: 'category',
searchMode: 'local-filter',
usesQuerySearch: true,
isPortalFavoritesAllScope: false,
})
);
expect(
parseWorkspaceShellRoute(
'/workspace/xtreams/pl-1/favorites?scope=all&q=neo'
)
).toEqual(
expect.objectContaining({
section: 'favorites',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
isPortalFavoritesAllScope: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/xtreams/pl-1/downloads')
).toEqual(
expect.objectContaining({
section: 'downloads',
contextPanel: 'collection',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
it('parses Stalker portal routes and detects context-panel/query-search state', () => {
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/itv')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'stalker',
playlistId: 'pl-2',
},
section: 'itv',
contextPanel: 'category',
searchMode: 'remote-search',
usesQuerySearch: true,
})
);
expect(
parseWorkspaceShellRoute('/workspace/stalker/pl-2/recent?q=test')
).toEqual(
expect.objectContaining({
section: 'recent',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
})
);
});
it('parses M3U portal routes without provider-specific workspace state', () => {
expect(
parseWorkspaceShellRoute('/workspace/playlists/pl-3/favorites')
).toEqual(
expect.objectContaining({
kind: 'portal',
context: {
provider: 'playlists',
playlistId: 'pl-3',
},
section: 'favorites',
contextPanel: 'none',
searchMode: 'local-filter',
usesQuerySearch: true,
isPortalFavoritesAllScope: false,
})
);
});
it('returns unknown for non-workspace URLs', () => {
expect(parseWorkspaceShellRoute('/xtreams/pl-1/search')).toEqual(
expect.objectContaining({
kind: 'unknown',
context: null,
section: null,
searchMode: 'none',
})
);
});
it('exposes query-search eligibility as a pure helper', () => {
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'xtreams', playlistId: '1' },
'favorites'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'xtreams', playlistId: '1' },
'vod'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'stalker', playlistId: '1' },
'recent'
)
).toBe(true);
expect(
usesWorkspaceRouteQuerySearch(
{ provider: 'playlists', playlistId: '1' },
'favorites'
)
).toBe(true);
});
});
@@ -0,0 +1,303 @@
import {
PortalProvider,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
export type WorkspaceShellPageKind =
| 'dashboard'
| 'downloads'
| 'global-favorites'
| 'global-recent'
| 'portal'
| 'settings'
| 'sources'
| 'unknown';
export type WorkspaceShellContextPanel =
| 'none'
| 'sources'
| 'settings'
| 'category'
| 'collection';
export interface WorkspacePortalContext {
provider: PortalProvider;
playlistId: string;
}
export type WorkspaceShellSearchMode =
| 'none'
| 'local-filter'
| 'remote-search'
| 'advanced-only';
export interface WorkspaceShellRoute {
kind: WorkspaceShellPageKind;
context: WorkspacePortalContext | null;
section: PortalRailSection | null;
isPortalFavoritesAllScope: boolean;
searchMode: WorkspaceShellSearchMode;
usesQuerySearch: boolean;
contextPanel: WorkspaceShellContextPanel;
}
const PORTAL_PROVIDERS: readonly PortalProvider[] = [
'xtreams',
'stalker',
'playlists',
];
const PORTAL_SECTIONS: readonly PortalRailSection[] = [
'all',
'downloads',
'favorites',
'groups',
'itv',
'library',
'live',
'recent',
'recently-added',
'search',
'series',
'vod',
];
const XTREAM_ROUTE_QUERY_SEARCH_SECTIONS = new Set<PortalRailSection>([
'favorites',
'downloads',
'recent',
'recently-added',
]);
const STALKER_ROUTE_QUERY_SEARCH_SECTIONS = new Set<PortalRailSection>([
'favorites',
'recent',
]);
const XTREAM_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
'vod',
'series',
'live',
]);
const STALKER_CATEGORY_CONTEXT_SECTIONS = new Set<PortalRailSection>([
'vod',
'series',
'itv',
]);
function normalizePath(url: string): {
segments: string[];
queryParams: URLSearchParams;
} {
const [path, query = ''] = url.split('?');
return {
segments: path.split('/').filter(Boolean),
queryParams: new URLSearchParams(query),
};
}
function asPortalProvider(value?: string): PortalProvider | null {
if (!value) {
return null;
}
return PORTAL_PROVIDERS.includes(value as PortalProvider)
? (value as PortalProvider)
: null;
}
function asPortalRailSection(value?: string): PortalRailSection | null {
if (!value) {
return null;
}
return PORTAL_SECTIONS.includes(value as PortalRailSection)
? (value as PortalRailSection)
: null;
}
export function usesWorkspaceRouteQuerySearch(
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): boolean {
return resolvePortalSearchMode(context, section) !== 'none';
}
function resolvePortalSearchMode(
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): Exclude<WorkspaceShellSearchMode, 'advanced-only'> {
if (!context || !section) {
return 'none';
}
if (context.provider === 'playlists') {
return section === 'all' ||
section === 'groups' ||
section === 'favorites' ||
section === 'recent'
? 'local-filter'
: 'none';
}
if (context.provider === 'xtreams') {
if (section === 'search') {
return 'remote-search';
}
return section === 'vod' ||
section === 'series' ||
section === 'live' ||
XTREAM_ROUTE_QUERY_SEARCH_SECTIONS.has(section)
? 'local-filter'
: 'none';
}
if (context.provider === 'stalker') {
if (
section === 'vod' ||
section === 'series' ||
section === 'itv' ||
section === 'search'
) {
return 'remote-search';
}
return STALKER_ROUTE_QUERY_SEARCH_SECTIONS.has(section) ||
section === 'downloads'
? 'local-filter'
: 'none';
}
return 'none';
}
function resolveRouteSearchMode(
kind: WorkspaceShellPageKind,
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): WorkspaceShellSearchMode {
if (kind === 'dashboard') {
return 'advanced-only';
}
if (
kind === 'sources' ||
kind === 'downloads' ||
kind === 'global-favorites' ||
kind === 'global-recent'
) {
return 'local-filter';
}
if (kind === 'portal') {
return resolvePortalSearchMode(context, section);
}
return 'none';
}
function resolveContextPanel(
kind: WorkspaceShellPageKind,
context: WorkspacePortalContext | null,
section: PortalRailSection | null
): WorkspaceShellContextPanel {
if (kind === 'sources') {
return 'sources';
}
if (kind === 'settings') {
return 'settings';
}
if (!context || !section) {
return 'none';
}
if (
(context.provider === 'xtreams' &&
XTREAM_CATEGORY_CONTEXT_SECTIONS.has(section)) ||
(context.provider === 'stalker' &&
STALKER_CATEGORY_CONTEXT_SECTIONS.has(section))
) {
return 'category';
}
if (
(context.provider === 'xtreams' || context.provider === 'stalker') &&
section === 'downloads'
) {
return 'collection';
}
return 'none';
}
export function parseWorkspaceShellRoute(url: string): WorkspaceShellRoute {
const { segments, queryParams } = normalizePath(url);
if (segments[0] !== 'workspace') {
return {
kind: 'unknown',
context: null,
section: null,
isPortalFavoritesAllScope: false,
searchMode: 'none',
usesQuerySearch: false,
contextPanel: 'none',
};
}
const sectionSegment = segments[3];
const provider = asPortalProvider(segments[1]);
if (provider && segments[2]) {
const context: WorkspacePortalContext = {
provider,
playlistId: segments[2],
};
const section = asPortalRailSection(sectionSegment);
return {
kind: 'portal',
context,
section,
isPortalFavoritesAllScope:
section === 'favorites' &&
(provider === 'xtreams' || provider === 'stalker') &&
queryParams.get('scope') === 'all',
searchMode: resolveRouteSearchMode('portal', context, section),
usesQuerySearch: usesWorkspaceRouteQuerySearch(context, section),
contextPanel: resolveContextPanel('portal', context, section),
};
}
const page = segments[1];
const kind: WorkspaceShellPageKind =
!page || page === 'dashboard'
? 'dashboard'
: page === 'sources'
? 'sources'
: page === 'settings'
? 'settings'
: page === 'global-favorites'
? 'global-favorites'
: page === 'global-recent'
? 'global-recent'
: page === 'downloads'
? 'downloads'
: 'unknown';
const searchMode = resolveRouteSearchMode(kind, null, null);
return {
kind,
context: null,
section: null,
isPortalFavoritesAllScope: false,
searchMode,
usesQuerySearch:
searchMode === 'local-filter' || searchMode === 'remote-search',
contextPanel: resolveContextPanel(kind, null, null),
};
}
@@ -0,0 +1,37 @@
import { Injectable, signal } from '@angular/core';
export interface SettingsNavItem {
id: string;
label: string;
icon: string;
}
@Injectable({ providedIn: 'root' })
export class SettingsContextService {
readonly sections = signal<SettingsNavItem[]>([]);
readonly activeSection = signal<string>('general');
readonly pendingScrollTarget = signal<string | null>(null);
setSections(items: SettingsNavItem[]): void {
this.sections.set(items);
}
setActiveSection(id: string): void {
this.activeSection.set(id);
}
navigateToSection(id: string): void {
this.activeSection.set(id);
this.pendingScrollTarget.set(id);
}
clearPendingScrollTarget(): void {
this.pendingScrollTarget.set(null);
}
reset(): void {
this.sections.set([]);
this.activeSection.set('general');
this.pendingScrollTarget.set(null);
}
}
@@ -0,0 +1,23 @@
import { InjectionToken } from '@angular/core';
import type { XtreamAccountInfoDialogData } from 'shared-interfaces';
export type WorkspacePlaylistType =
| 'xtream'
| 'url'
| 'text'
| 'file'
| 'stalker';
export type PlaylistCategory = 'm3u' | 'xtream' | 'stalker';
export type M3uSubType = 'url' | 'file' | 'text';
export type WorkspaceAccountInfoData = XtreamAccountInfoDialogData;
export interface WorkspaceShellActions {
openAddPlaylistDialog(type?: WorkspacePlaylistType): void;
openGlobalSearch(initialQuery?: string): void;
openGlobalRecent(): void;
openAccountInfo(data: WorkspaceAccountInfoData): void;
}
export const WORKSPACE_SHELL_ACTIONS =
new InjectionToken<WorkspaceShellActions>('WORKSPACE_SHELL_ACTIONS');
@@ -0,0 +1,104 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { PlaylistsService, SettingsStore } from 'services';
import { StartupBehavior } from 'shared-interfaces';
import { WorkspaceStartupPreferencesService } from './workspace-startup-preferences.service';
describe('WorkspaceStartupPreferencesService', () => {
let service: WorkspaceStartupPreferencesService;
let playlistsService: { getAllPlaylists: jest.Mock };
let settingsStore: {
loadSettings: jest.Mock;
showDashboard: ReturnType<typeof signal<boolean>>;
startupBehavior: ReturnType<typeof signal<StartupBehavior>>;
};
beforeEach(() => {
localStorage.clear();
playlistsService = {
getAllPlaylists: jest.fn().mockReturnValue(
of([{ _id: 'playlist-1' }])
),
};
settingsStore = {
loadSettings: jest.fn().mockResolvedValue(undefined),
showDashboard: signal(true),
startupBehavior: signal(StartupBehavior.FirstView),
};
TestBed.configureTestingModule({
providers: [
WorkspaceStartupPreferencesService,
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: SettingsStore,
useValue: settingsStore,
},
],
});
service = TestBed.inject(WorkspaceStartupPreferencesService);
});
it('resolves the first view to dashboard when dashboard is enabled', async () => {
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/dashboard'
);
});
it('resolves the first view to sources when dashboard is hidden', async () => {
settingsStore.showDashboard.set(false);
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/sources'
);
});
it('restores the last route when restore-last-view is enabled', async () => {
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
service.persistLastRestorablePath('/workspace/global-recent?q=matrix');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/global-recent'
);
});
it('falls back to sources when the stored dashboard route is hidden', async () => {
settingsStore.showDashboard.set(false);
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
service.persistLastRestorablePath('/workspace/dashboard');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/sources'
);
});
it('canonicalizes detail routes to their section root', () => {
expect(
service.getRestorablePath(
'/workspace/xtreams/playlist-1/vod/123/456?q=matrix'
)
).toBe('/workspace/xtreams/playlist-1/vod');
});
it('ignores non-restorable routes', () => {
expect(service.getRestorablePath('/workspace/settings')).toBeNull();
expect(service.getRestorablePath('/workspace')).toBeNull();
expect(service.getRestorablePath('/unknown')).toBeNull();
});
it('falls back to the first available view when the stored playlist no longer exists', async () => {
settingsStore.startupBehavior.set(StartupBehavior.RestoreLastView);
playlistsService.getAllPlaylists.mockReturnValue(of([]));
service.persistLastRestorablePath('/workspace/xtreams/missing/live');
await expect(service.resolveInitialWorkspacePath()).resolves.toBe(
'/workspace/dashboard'
);
});
});
@@ -0,0 +1,153 @@
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistsService, SettingsStore } from 'services';
import {
StartupBehavior,
} from 'shared-interfaces';
import { parseWorkspaceShellRoute } from './navigation/workspace-shell-route.utils';
const LAST_RESTORABLE_ROUTE_STORAGE_KEY = 'workspace-last-restorable-route-v1';
@Injectable({ providedIn: 'root' })
export class WorkspaceStartupPreferencesService {
private readonly settingsStore = inject(SettingsStore);
private readonly playlistsService = inject(PlaylistsService);
async resolveInitialWorkspacePath(): Promise<string> {
await this.settingsStore.loadSettings();
const showDashboard = this.showDashboard();
const firstViewPath = this.getFirstAvailableWorkspacePath(showDashboard);
if (this.startupBehavior() !== StartupBehavior.RestoreLastView) {
return firstViewPath;
}
return (
(await this.getValidatedLastRestorablePath(showDashboard)) ??
firstViewPath
);
}
async resolveDashboardPath(): Promise<string> {
await this.settingsStore.loadSettings();
return this.showDashboard()
? '/workspace/dashboard'
: '/workspace/sources';
}
getFirstAvailableWorkspacePath(showDashboard = this.showDashboard()): string {
return showDashboard ? '/workspace/dashboard' : '/workspace/sources';
}
showDashboard(): boolean {
return this.settingsStore.showDashboard?.() ?? true;
}
startupBehavior(): StartupBehavior {
return (
this.settingsStore.startupBehavior?.() ??
StartupBehavior.FirstView
);
}
persistLastRestorablePath(url: string): void {
const canonicalPath = this.getRestorablePath(url);
if (!canonicalPath) {
return;
}
try {
localStorage.setItem(
LAST_RESTORABLE_ROUTE_STORAGE_KEY,
canonicalPath
);
} catch {
// Ignore storage write failures.
}
}
getRestorablePath(url: string): string | null {
const [path] = url.split('?');
if (path === '/workspace' || path === '/workspace/') {
return null;
}
const route = parseWorkspaceShellRoute(url);
switch (route.kind) {
case 'dashboard':
return '/workspace/dashboard';
case 'downloads':
return '/workspace/downloads';
case 'global-favorites':
return '/workspace/global-favorites';
case 'global-recent':
return '/workspace/global-recent';
case 'sources':
return '/workspace/sources';
case 'portal':
if (!route.context || !route.section) {
return null;
}
return [
'/workspace',
route.context.provider,
route.context.playlistId,
route.section,
].join('/');
default:
return null;
}
}
async getValidatedLastRestorablePath(
showDashboard = this.showDashboard()
): Promise<string | null> {
const storedPath = this.readLastRestorablePath();
if (!storedPath) {
return null;
}
const canonicalPath = this.getRestorablePath(storedPath);
if (!canonicalPath) {
return null;
}
if (!showDashboard && canonicalPath === '/workspace/dashboard') {
return this.getFirstAvailableWorkspacePath(false);
}
const route = parseWorkspaceShellRoute(canonicalPath);
if (route.kind !== 'portal' || !route.context) {
return canonicalPath;
}
try {
const playlists = await firstValueFrom(
this.playlistsService.getAllPlaylists()
);
return playlists.some(
(playlist) => playlist._id === route.context?.playlistId
)
? canonicalPath
: this.getFirstAvailableWorkspacePath(showDashboard);
} catch {
return this.getFirstAvailableWorkspacePath(showDashboard);
}
}
private readLastRestorablePath(): string | null {
try {
const value = localStorage.getItem(
LAST_RESTORABLE_ROUTE_STORAGE_KEY
);
return value && value.trim().length > 0 ? value : null;
} catch {
return null;
}
}
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}