diff --git a/packages/core/src/utils/config.ts b/packages/core/src/utils/config.ts index c7a8562cb..a400360e7 100644 --- a/packages/core/src/utils/config.ts +++ b/packages/core/src/utils/config.ts @@ -446,7 +446,7 @@ export async function validateConfig( ...(config.preferredStreamExpressions?.map((e) => e.expression) ?? []), ...(config.includedStreamExpressions?.map((e) => e.expression) ?? []), ...(config.rankedStreamExpressions?.map((r) => r.expression) ?? []), - ]; + ].filter((expr) => !(expr.startsWith(''))); for (const expression of expressionsToValidate) { try { @@ -863,7 +863,7 @@ async function validateRegexes(config: UserData, skipErrors: boolean = false) { ...requiredRegexes, ...preferredRegexes.map((regex) => regex.pattern), ...rankedRegexes.map((regex) => regex.pattern), - ]; + ].filter((pattern) => !(pattern.startsWith(''))); if (!regexAllowed && regexes.length > 0) { const allowedPatterns = (await RegexAccess.allowedRegexPatterns()).patterns; @@ -910,12 +910,28 @@ function validateSyncedRegexUrls( if (isUnrestricted) return; + const SYNCED_PREFIX = ' patterns in expression arrays + const extractSyncedUrls = (patterns: (string | { pattern?: string })[]): string[] => + patterns + .map((p) => (typeof p === 'string' ? p : p.pattern || '')) + .filter((v) => v.startsWith(SYNCED_PREFIX) && v.endsWith(SYNCED_SUFFIX)) + .map((v) => v.slice(SYNCED_PREFIX.length, -SYNCED_SUFFIX.length).trim()); + const allowedUrls = RegexAccess.getAllowedUrls(); const urlsToCheck = [ ...(config.syncedIncludedRegexUrls || []), ...(config.syncedExcludedRegexUrls || []), ...(config.syncedRequiredRegexUrls || []), ...(config.syncedPreferredRegexUrls || []), + ...(config.syncedRankedRegexUrls || []), + ...extractSyncedUrls(config.excludedRegexPatterns || []), + ...extractSyncedUrls(config.includedRegexPatterns || []), + ...extractSyncedUrls(config.requiredRegexPatterns || []), + ...extractSyncedUrls(config.preferredRegexPatterns || []), + ...extractSyncedUrls(config.rankedRegexPatterns || []), ]; const invalidUrls = urlsToCheck.filter((url) => !allowedUrls.includes(url)); @@ -936,6 +952,18 @@ function validateSyncedSelUrls(config: UserData, skipErrors: boolean = false) { if (isUnrestricted) return; + const SYNCED_PREFIX = ' patterns in expression arrays + const extractSyncedUrls = ( + expressions: (string | { expression?: string })[] + ): string[] => + expressions + .map((e) => (typeof e === 'string' ? e : e.expression || '')) + .filter((v) => v.startsWith(SYNCED_PREFIX) && v.endsWith(SYNCED_SUFFIX)) + .map((v) => v.slice(SYNCED_PREFIX.length, -SYNCED_SUFFIX.length).trim()); + const allowedUrls = SelAccess.getAllowedUrls(); const urlsToCheck = [ ...(config.syncedIncludedStreamExpressionUrls || []), @@ -943,6 +971,11 @@ function validateSyncedSelUrls(config: UserData, skipErrors: boolean = false) { ...(config.syncedRequiredStreamExpressionUrls || []), ...(config.syncedPreferredStreamExpressionUrls || []), ...(config.syncedRankedStreamExpressionUrls || []), + ...extractSyncedUrls(config.includedStreamExpressions || []), + ...extractSyncedUrls(config.excludedStreamExpressions || []), + ...extractSyncedUrls(config.requiredStreamExpressions || []), + ...extractSyncedUrls(config.preferredStreamExpressions || []), + ...extractSyncedUrls(config.rankedStreamExpressions || []), ]; const invalidUrls = urlsToCheck.filter((url) => !allowedUrls.includes(url)); diff --git a/packages/core/src/utils/regex-access.ts b/packages/core/src/utils/regex-access.ts index c26ac2768..1d0659939 100644 --- a/packages/core/src/utils/regex-access.ts +++ b/packages/core/src/utils/regex-access.ts @@ -264,37 +264,68 @@ export class RegexAccess { transform: (item: RegexPatternItem) => U, uniqueKey: (item: U) => string ): Promise { - const patterns = await this.resolvePatterns(urls, userData); - if (patterns.length === 0) return existing; - const result = [...existing]; - const existingSet = new Set(existing.map(uniqueKey)); const overrides: SyncOverride[] = userData.regexOverrides || []; - for (const regex of patterns) { - const override = overrides.find( - (o) => - o.pattern === regex.pattern || - (regex.name && o.originalName === regex.name) - ); + // Helper to process and transform patterns from a URL + const processPatterns = async (url: string): Promise => { + const patterns = await this.resolvePatterns([url], userData); + const syncedItems: U[] = []; + for (const regex of patterns) { + const override = overrides.find( + (o) => + o.pattern === regex.pattern || + (regex.name && o.originalName === regex.name) + ); - if (override?.disabled) continue; - - const item = transform( - override - ? { - ...regex, - name: override.name ?? regex.name, - score: - override.score !== undefined ? override.score : regex.score, - } - : regex - ); + if (override?.disabled) continue; + + const item = transform( + override + ? { + ...regex, + name: override.name ?? regex.name, + score: + override.score !== undefined ? override.score : regex.score, + } + : regex + ); + + syncedItems.push(item); + } + return syncedItems; + }; + + const SYNCED_PREFIX = '(); + for (const item of existing) { const key = uniqueKey(item); - if (!existingSet.has(key)) { + + if (key.startsWith(SYNCED_PREFIX) && key.endsWith(SYNCED_SUFFIX)) { + const url = key.slice(SYNCED_PREFIX.length, -SYNCED_SUFFIX.length).trim(); + + if (url) { + usedUrls.add(url); + const syncedItems = await processPatterns(url); + result.push(...syncedItems); + } else { + result.push(item); + } + } else { result.push(item); - existingSet.add(key); + } + } + + // Legacy fallback: append URLs from the `urls` array not already handled above + if (urls && urls.length > 0) { + for (const url of urls) { + if (!usedUrls.has(url)) { + const syncedItems = await processPatterns(url); + result.push(...syncedItems); + } } } diff --git a/packages/core/src/utils/sel-access.ts b/packages/core/src/utils/sel-access.ts index 4bbc4c20e..ce660c018 100644 --- a/packages/core/src/utils/sel-access.ts +++ b/packages/core/src/utils/sel-access.ts @@ -167,27 +167,63 @@ export class SelAccess { transform: (item: StreamExpressionItem) => U, uniqueKey: (item: U) => string ): Promise { - const expressions = await this.resolveExpressions(urls, userData); - if (expressions.length === 0) return existing; - const result = [...existing]; - const existingSet = new Set(existing.map(uniqueKey)); const overrides: SyncOverride[] = userData.selOverrides || []; - for (const expr of expressions) { - const override = this._findSelOverride(expr, overrides); + // Helper to process and transform expressions from a URL + const processExpressions = async (url: string): Promise => { + const expressions = await this.resolveExpressions([url], userData); + const syncedItems: U[] = []; + for (const expr of expressions) { + const override = this._findSelOverride(expr, overrides); + if (override?.disabled) continue; - if (override?.disabled) continue; + const overriddenExpr = override + ? this._applySelOverride(expr, override) + : expr; - const overriddenExpr = override - ? this._applySelOverride(expr, override) - : expr; + const item = transform(overriddenExpr); + syncedItems.push(item); + } + return syncedItems; + }; + + const SYNCED_PREFIX = '(); - const item = transform(overriddenExpr); + for (const item of existing) { const key = uniqueKey(item); - if (!existingSet.has(key)) { + + if (key.startsWith(SYNCED_PREFIX) && key.endsWith(SYNCED_SUFFIX)) { + const isPlaceholderDisabled = + typeof item === 'object' && item !== null && (item as any).enabled === false; + + const url = key.slice(SYNCED_PREFIX.length, -SYNCED_SUFFIX.length).trim(); + + if (isPlaceholderDisabled) { + usedUrls.add(url); + result.push(item); // Keep disabled placeholder without fetching + } else if (url) { + usedUrls.add(url); + const syncedItems = await processExpressions(url); + result.push(...syncedItems); + } else { + result.push(item); + } + } else { result.push(item); - existingSet.add(key); + } + } + + // Legacy fallback: append URLs from the `urls` array not already handled above + if (urls && urls.length > 0) { + for (const url of urls) { + if (!usedUrls.has(url)) { + const syncedItems = await processExpressions(url); + result.push(...syncedItems); + } } } diff --git a/packages/frontend/src/components/config-modal.tsx b/packages/frontend/src/components/config-modal.tsx index 2eb45ef29..18156b7f0 100644 --- a/packages/frontend/src/components/config-modal.tsx +++ b/packages/frontend/src/components/config-modal.tsx @@ -3,7 +3,7 @@ import { Modal } from '@/components/ui/modal'; import { TextInput } from '@/components/ui/text-input'; import { Button } from '@/components/ui/button'; import { loadUserConfig, APIError } from '@/lib/api'; -import { useUserData } from '@/context/userData'; +import { useUserData, applyMigrations } from '@/context/userData'; import { toast } from 'sonner'; import { PasswordInput } from './ui/password-input'; @@ -34,7 +34,7 @@ export function ConfigModal({ try { const result = await loadUserConfig(uuid, password); - setUserData(() => result.userData); + setUserData(() => applyMigrations(result.userData)); setUuid(uuid); setPassword(password); setEncryptedPassword(result.encryptedPassword); diff --git a/packages/frontend/src/components/menu/filters/_components/filter-inputs.tsx b/packages/frontend/src/components/menu/filters/_components/filter-inputs.tsx index 1bb0b85ad..427b495fa 100644 --- a/packages/frontend/src/components/menu/filters/_components/filter-inputs.tsx +++ b/packages/frontend/src/components/menu/filters/_components/filter-inputs.tsx @@ -175,6 +175,63 @@ function ListFooter({ ); } +const SYNCED_PREFIX = '` tag, or returns empty string. */ +function parseSyncedUrl(tag: string): string { + return tag.startsWith(SYNCED_PREFIX) && tag.endsWith(SYNCED_SUFFIX) + ? tag.slice(SYNCED_PREFIX.length, -SYNCED_SUFFIX.length).trim() + : ''; +} + +/** Provides a callback for adding new synced URLs and computes existing synced URLs for duplicate detection. */ +function useSyncedUrls( + valuesRef: React.MutableRefObject, + onValuesChange: (values: T[]) => void, + getKey: (item: T) => string, + createItem: (url: string) => T +) { + const handleUrlAdded = useCallback( + (url: string) => { + onValuesChange([...valuesRef.current, createItem(url)]); + }, + [onValuesChange] + ); + + const existingUrls = valuesRef.current + .map((v) => parseSyncedUrl(getKey(v))) + .filter(Boolean); + + return { handleUrlAdded, existingUrls }; +} + +// Placeholder inline container for synced URLs +function PlaceholderSyncedUrls({ + syncConfig, + renderType, + url, + disabled, +}: { + syncConfig: SyncConfig; + renderType: 'simple' | 'nameable' | 'ranked'; + url: string; + disabled?: boolean; +}) { + return ( +
+ + +
+ ); +} + // TextInputs export type TextInputProps = { @@ -230,27 +287,49 @@ export function TextInputs({ [onValuesChange] ); + const { handleUrlAdded, existingUrls } = useSyncedUrls( + valuesRef, + onValuesChange, + (v) => v, + (url) => `${SYNCED_PREFIX}${url}${SYNCED_SUFFIX}` + ); + return ( - {values.map((value, index) => ( -
-
- handleValueChange(newValue, index)} - /> -
-
- + {values.map((value, index) => { + const syncedUrl = parseSyncedUrl(value); + + return ( +
+
+ {syncedUrl && syncConfig ? ( + + ) : ( + { + if (newValue.includes(' + )} +
+
+ +
-
- ))} + ); + })} onValuesChange([...values, ''])} onImportClick={modal.open} @@ -262,7 +341,13 @@ export function TextInputs({ onImport={handleImport} /> {syncConfig && ( - + )} ); @@ -336,40 +421,63 @@ export function ToggleableTextInputs({ title ); + const { handleUrlAdded, existingUrls } = useSyncedUrls( + valuesRef, + onValuesChange, + (v) => v.expression, + (url) => ({ expression: `${SYNCED_PREFIX}${url}${SYNCED_SUFFIX}`, enabled: true }) + ); + return ( - {values.map((value, index) => ( -
-
- { - if (onEnabledChange) { - onEnabledChange(v === true, index); - } - }} - /> -
-
- onExpressionChange(newValue, index)} - /> -
-
- + {values.map((value, index) => { + const syncedUrl = parseSyncedUrl(value.expression); + + return ( +
+
+ { + if (onEnabledChange) { + onEnabledChange(v === true, index); + } + }} + /> +
+
+ {syncedUrl && syncConfig ? ( + + ) : ( + { + if (newValue.includes(' + )} +
+
+ +
-
- ))} + ); + })} onValuesChange([...values, { expression: '', enabled: true }]) @@ -383,7 +491,13 @@ export function ToggleableTextInputs({ onImport={handleImport} /> {syncConfig && ( - + )} ); @@ -459,35 +573,62 @@ export function TwoTextInputs({ title ); + const { handleUrlAdded, existingUrls } = useSyncedUrls( + valuesRef, + onValuesChange, + (v) => v.name, + (url) => ({ name: `${SYNCED_PREFIX}${url}${SYNCED_SUFFIX}`, value: `${SYNCED_PREFIX}${url}${SYNCED_SUFFIX}` }) + ); + return ( - {values.map((value, index) => ( -
-
- onKeyChange(newValue, index)} - /> -
-
- onValueChange(newValue, index)} - /> -
-
- + {values.map((value, index) => { + const syncedUrl = parseSyncedUrl(value.name); + + return ( +
+ {!syncedUrl && ( +
+ { + if (newValue.includes(' +
+ )} +
+ {syncedUrl && syncConfig ? ( + + ) : ( + { + if (newValue.includes(' + )} +
+
+ +
-
- ))} + ); + })} onValuesChange([...values, { name: '', value: '' }])} onImportClick={modal.open} @@ -499,7 +640,13 @@ export function TwoTextInputs({ onImport={handleImport} /> {syncConfig && ( - + )} ); @@ -572,52 +719,76 @@ export function RankedExpressionInputs({ title ); + const { handleUrlAdded, existingUrls } = useSyncedUrls( + valuesRef, + onValuesChange, + (v) => v.expression, + (url) => ({ expression: `${SYNCED_PREFIX}${url}${SYNCED_SUFFIX}`, score: 0, enabled: true }) + ); + return ( - {values.map((value, index) => ( -
-
- { - if (onEnabledChange) { - onEnabledChange(v === true, index); - } - }} - /> -
-
- onExpressionChange(newValue, index)} - /> -
-
- onScoreChange(newValue || 0, index)} - min={-1_000_000} - max={1_000_000} - step={50} - /> -
-
- + {values.map((value, index) => { + const syncedUrl = parseSyncedUrl(value.expression); + + return ( +
+
+ { + if (onEnabledChange) { + onEnabledChange(v === true, index); + } + }} + /> +
+
+ {syncedUrl && syncConfig ? ( + + ) : ( + { + if (newValue.includes(' + )} +
+ {!syncedUrl && ( +
+ onScoreChange(newValue || 0, index)} + min={-1_000_000} + max={1_000_000} + step={50} + /> +
+ )} +
+ +
-
- ))} + ); + })} onValuesChange([ @@ -634,7 +805,13 @@ export function RankedExpressionInputs({ onImport={handleImport} /> {syncConfig && ( - + )} ); @@ -705,52 +882,78 @@ export function RankedRegexInputs({ title ); + const { handleUrlAdded, existingUrls } = useSyncedUrls( + valuesRef, + onValuesChange, + (v) => v.pattern, + (url) => ({ pattern: `${SYNCED_PREFIX}${url}${SYNCED_SUFFIX}`, name: url, score: 0 }) + ); + return ( - {values.map((value, index) => ( -
-
- onPatternChange(newValue, index)} - /> -
-
-
- onNameChange(newValue, index)} - /> -
-
- - onScoreChange(newValue ?? 0, index) - } - min={-1_000_000} - max={1_000_000} - step={50} - /> + {values.map((value, index) => { + const syncedUrl = parseSyncedUrl(value.pattern); + + return ( +
+
+ {syncedUrl && syncConfig ? ( + + ) : ( + { + if (newValue.includes(' + )}
-
- +
+ {!syncedUrl && ( + <> +
+ onNameChange(newValue, index)} + /> +
+
+ + onScoreChange(newValue ?? 0, index) + } + min={-1_000_000} + max={1_000_000} + step={50} + /> +
+ + )} +
+ +
-
- ))} + ); + })} onValuesChange([...values, { pattern: '', name: '', score: 0 }]) @@ -764,7 +967,13 @@ export function RankedRegexInputs({ onImport={handleImport} /> {syncConfig && ( - + )} ); diff --git a/packages/frontend/src/components/menu/filters/_components/synced-patterns.tsx b/packages/frontend/src/components/menu/filters/_components/synced-patterns.tsx index d22c95042..805327d25 100644 --- a/packages/frontend/src/components/menu/filters/_components/synced-patterns.tsx +++ b/packages/frontend/src/components/menu/filters/_components/synced-patterns.tsx @@ -59,7 +59,6 @@ export type SyncMode = 'regex' | 'sel'; export interface SyncConfig { urls: string[]; - onUrlsChange: (urls: string[]) => void; trusted?: boolean; syncMode?: SyncMode; } @@ -672,9 +671,21 @@ interface UrlFetchState { export function SyncedUrlInputs({ syncConfig, renderType = 'simple', + hideHeader = false, + hideList = false, + hideAddForm = false, + onUrlAdded, + existingUrls, + className, }: { syncConfig?: SyncConfig; renderType?: 'simple' | 'nameable' | 'ranked'; + hideHeader?: boolean; + hideList?: boolean; + hideAddForm?: boolean; + onUrlAdded?: (url: string) => void; + existingUrls?: string[]; + className?: string; }) { const { status } = useStatus(); const { userData, password } = useUserData(); @@ -764,13 +775,16 @@ export function SyncedUrlInputs({ } return () => abortController.abort(); - }, [syncConfig?.urls?.join(','), userData.uuid, password, syncMode]); + }, [JSON.stringify(syncConfig?.urls), userData.uuid, password, syncMode]); if (!syncConfig) { return null; } - const { urls, onUrlsChange, trusted } = syncConfig; + const { urls, trusted } = syncConfig; + + // URLs already present as blocks, used for duplicate detection + const knownUrls = existingUrls || []; const validateAndAdd = (url: string) => { const allowedUrls = status?.settings?.regexAccess?.urls || []; @@ -784,7 +798,7 @@ export function SyncedUrlInputs({ return false; } - if (urls.includes(url)) { + if (knownUrls.includes(url)) { toast.error('URL is already added'); return false; } @@ -821,35 +835,39 @@ export function SyncedUrlInputs({ return true; }; - const handleUrlsUpdate = (newUrls: string[]) => { - onUrlsChange(newUrls); - }; - const handleAdd = () => { if (!validateAndAdd(newUrl)) return; - handleUrlsUpdate([...urls, newUrl]); setNewUrl(''); + onUrlAdded?.(newUrl); }; return ( -
-
-
- Synced URLs - {urls.length > 0 && ( - - ({urls.length}) - - )} -
-

- Automatically fetch and sync {itemLabel} from URLs -

-
+
+ {!hideHeader && ( +
+
+ Synced URLs + {knownUrls.length > 0 && ( + + ({knownUrls.length}) + + )} +
+

+ Automatically fetch and sync {itemLabel} from URLs +

+
+ )}
- {urls.length > 0 && ( + {!hideList && urls.length > 0 && (
{urls.map((url) => { const urlState = fetchedData[url]; @@ -865,18 +883,9 @@ export function SyncedUrlInputs({ - + {url} - } - intent="alert-subtle" - onClick={() => - handleUrlsUpdate(urls.filter((u) => u !== url)) - } - />
)} -
-
- { - if (e.key === 'Enter') { - e.preventDefault(); - handleAdd(); - } - }} + {!hideAddForm && ( +
+
+ { + if (e.key === 'Enter') { + e.preventDefault(); + handleAdd(); + } + }} + /> +
+ } + rounded + intent="primary-subtle" />
- } - rounded - intent="primary-subtle" - /> -
+ )}
); diff --git a/packages/frontend/src/components/menu/filters/index.tsx b/packages/frontend/src/components/menu/filters/index.tsx index 7ebaeebf1..311b337e9 100644 --- a/packages/frontend/src/components/menu/filters/index.tsx +++ b/packages/frontend/src/components/menu/filters/index.tsx @@ -144,6 +144,7 @@ function Content() { | 'syncedExcludedRegexUrls' | 'syncedIncludedRegexUrls' | 'syncedRequiredRegexUrls' + | 'syncedRankedRegexUrls' | 'syncedPreferredStreamExpressionUrls' | 'syncedExcludedStreamExpressionUrls' | 'syncedIncludedStreamExpressionUrls' @@ -154,12 +155,6 @@ function Content() { urls: userData[key] || [], trusted: userData.trusted, syncMode: key.includes('StreamExpression') ? 'sel' : 'regex', - onUrlsChange: (urls: string[]) => { - setUserData((prev) => ({ - ...prev, - [key]: urls, - })); - }, }, }); @@ -2561,15 +2556,7 @@ function Content() { ], })); }} - syncConfig={{ - urls: userData.syncedRankedRegexUrls || [], - onUrlsChange: (urls) => - setUserData((prev) => ({ - ...prev, - syncedRankedRegexUrls: urls, - })), - trusted: userData.trusted, - }} + {...getSyncedProps('syncedRankedRegexUrls')} />
diff --git a/packages/frontend/src/context/userData.tsx b/packages/frontend/src/context/userData.tsx index 02522ae0c..84b452fdb 100644 --- a/packages/frontend/src/context/userData.tsx +++ b/packages/frontend/src/context/userData.tsx @@ -232,6 +232,42 @@ export function applyMigrations(config: any): UserData { }); } + // migrate synced URLs from legacy arrays into placeholders + const syncedTag = (url: string) => ``; + + const migrateSyncedUrls = ( + legacyKey: string, + valuesKey: string, + getKey: (item: any) => string, + createItem: (url: string) => any + ) => { + const urls = (config as any)[legacyKey]; + if (!Array.isArray(urls) || urls.length === 0) return; + const values: any[] = Array.isArray((config as any)[valuesKey]) ? (config as any)[valuesKey] : []; + for (const url of urls) { + if (!values.some((v: any) => v != null && getKey(v) === syncedTag(url))) { + values.push(createItem(url)); + } + } + (config as any)[valuesKey] = values; + delete (config as any)[legacyKey]; + }; + + // included/excluded/required regex + for (const type of ['Included', 'Excluded', 'Required']) { + migrateSyncedUrls(`synced${type}RegexUrls`, `${type.toLowerCase()}RegexPatterns`, (v) => v, (url) => syncedTag(url)); + } + // preferred regex + migrateSyncedUrls('syncedPreferredRegexUrls', 'preferredRegexPatterns', (v) => v.pattern, (url) => ({ name: syncedTag(url), pattern: syncedTag(url) })); + // ranked regex + migrateSyncedUrls('syncedRankedRegexUrls', 'rankedRegexPatterns', (v) => v.pattern, (url) => ({ pattern: syncedTag(url), name: url, score: 0 })); + // included/excluded/required/preferred SEL + for (const type of ['Included', 'Excluded', 'Required', 'Preferred']) { + migrateSyncedUrls(`synced${type}StreamExpressionUrls`, `${type.toLowerCase()}StreamExpressions`, (v) => v.expression, (url) => ({ expression: syncedTag(url), enabled: true })); + } + // ranked SEL + migrateSyncedUrls('syncedRankedStreamExpressionUrls', 'rankedStreamExpressions', (v) => v.expression, (url) => ({ expression: syncedTag(url), score: 0, enabled: true })); + return config; }