Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 120 additions & 1 deletion frontend-ui/src/components/ScheduleEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,52 @@
{{ getGraphemeCount(editFlags[field.dataKey]) }}/{{ field.max_length }}
</template>
</v-text-field>
<div v-if="showYoutubeLinks && isIdentField(field)" class="mt-1">
<div class="d-flex flex-wrap align-center" style="gap: 6px">
<template v-for="item in youtubeLinkItems" :key="`${item.raw}-${item.kind}`">
<v-tooltip v-if="item.url" location="top">
<template #activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
variant="tonal"
size="small"
:color="getYoutubeLinkColor(item.kind)"
:prepend-icon="getYoutubeLinkIcon(item.kind)"
class="youtube-link-chip"
>
{{
item.kind === 'handle'
? item.raw
: item.raw.substring(0, 12) + (item.raw.length > 12 ? '...' : '')
}}
</v-chip>
</template>
<div class="text-caption">
{{ item.url }}
</div>
</v-tooltip>
<v-tooltip v-else location="top">
<template #activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
disabled
variant="outlined"
size="small"
color="grey"
prepend-icon="mdi-help-circle"
class="youtube-link-chip"
>
{{ item.raw.substring(0, 12) + (item.raw.length > 12 ? '...' : '') }}
</v-chip>
</template>
<span class="text-caption">Unknown YouTube ID format</span>
</v-tooltip>
</template>
</div>
</div>
</v-col>
<v-divider class="mt-1"></v-divider>
</v-row>
Expand Down Expand Up @@ -627,6 +673,7 @@
} from '@/types/schedule'
import { fuzzyFilter, stringArrayEqual } from '@/utils/cmp'
import { formattedBytesSize } from '@/utils/format'
import { buildYoutubeLinks, type YoutubeLinkKind } from '@/utils/youtube'
import diff from 'deep-diff'
import { byGrapheme } from 'split-by-grapheme'
import { computed, onUnmounted, ref, watch } from 'vue'
Expand Down Expand Up @@ -935,105 +982,105 @@
return hasChanges.value && areAllFieldsValid.value
})

const hasChanges = computed<boolean>(() => {
if (!(props.schedule && editSchedule.value)) return false

// Check basic schedule properties
const basicProps: Array<keyof Schedule> = ['category', 'name', 'enabled', 'periodicity']
for (const prop of basicProps) {
if (editSchedule.value[prop] !== props.schedule[prop]) return true
}

// Check version
if (
editSchedule.value.version &&
props.schedule.version &&
editSchedule.value.version !== props.schedule.version
)
return true

// Check context with null/empty string equivalence
const originalContext = props.schedule.context
const editedContext = editSchedule.value.context
if (originalContext !== editedContext) {
// Consider null and empty string as equivalent
if (
!(
(originalContext === null || originalContext === '') &&
(editedContext === null || editedContext === '')
)
) {
return true
}
}

// Check tags
if (!stringArrayEqual(editSchedule.value.tags, props.schedule.tags)) return true

// Check language
if (editSchedule.value.language.code !== props.schedule.language.code) return true

// Check config properties
const configProps: Array<keyof ScheduleConfig> = ['warehouse_path', 'platform', 'monitor']
for (const prop of configProps) {
if (editSchedule.value.config[prop] !== props.schedule.config[prop]) return true
}

// Check image
if (
editSchedule.value.config.image.name !== props.schedule.config.image.name ||
editSchedule.value.config.image.tag !== props.schedule.config.image.tag
)
return true

// Check resources
const resourceProps: Array<keyof Resources> = ['cpu', 'memory', 'disk', 'shm']
for (const prop of resourceProps) {
if (editSchedule.value.config.resources[prop] !== props.schedule.config.resources[prop])
return true
}

// check offliner id
if (editSchedule.value.config.offliner.offliner_id !== props.schedule.config.offliner.offliner_id)
return true

// Check artifacts globs
const artifacts_globs = processArtifactsGlobs(editSchedule.value.config.artifacts_globs_str)

if (!stringArrayEqual(artifacts_globs, props.schedule.config.artifacts_globs || [])) return true

// Check notifications
if (!notificationsEqual(editSchedule.value.notification, props.schedule.notification)) return true

let changes = diff(props.schedule.config.offliner, editFlags.value)

if (!changes) return false

// eslint-disable-next-line @typescript-eslint/no-explicit-any
changes = changes.filter(function (change: any) {
// Filter out empty changes (new empty fields or fields changed to empty)
if (
change.kind === 'N' &&
(change.rhs === '' || change.rhs === undefined || change.rhs === null)
) {
return false
}
if (change.kind === 'E') {
// if we are toggling a switch to false and it's a null on the original object,
// then it's not a change
if (change.lhs === null && change.rhs === false) return false
// If changing from null/empty to null/empty, it's not a change
if (
(change.lhs === null || change.lhs === '') &&
(change.rhs === undefined || change.rhs === null || change.rhs === '')
) {
return false
}
}
return true
})
return changes.length > 0
})

Check notice on line 1083 in frontend-ui/src/components/ScheduleEditor.vue

View check run for this annotation

codefactor.io / CodeFactor

frontend-ui/src/components/ScheduleEditor.vue#L985-L1083

Complex Method

const flagsFields = computed(() => {
if (!props.flagsDefinition) return []
Expand Down Expand Up @@ -1097,6 +1144,69 @@
})
})

const isYoutubeOffliner = computed(() => {
const selected = editSchedule.value.config.offliner.offliner_id
const fallback = props.schedule.config.offliner.offliner_id
return (selected || fallback) === 'youtube'
})

const youtubeIdentField = computed(() => {
if (!isYoutubeOffliner.value) return null
return flagsFields.value.find((field) => field.key === 'ident' || field.dataKey === 'id') || null
})

const youtubeIdentValue = computed(() => {
const field = youtubeIdentField.value
if (!field) return ''
const value = editFlags.value[field.dataKey]
return typeof value === 'string' ? value : ''
})

const youtubeLinkItems = computed(() => buildYoutubeLinks(youtubeIdentValue.value))

const showYoutubeLinks = computed(() => {
return (
isYoutubeOffliner.value &&
!!youtubeIdentField.value &&
youtubeIdentValue.value.trim().length > 0 &&
youtubeLinkItems.value.length > 0
)
})

const isIdentField = (field: FlagField) => {
return field === youtubeIdentField.value
}

const getYoutubeLinkColor = (kind: YoutubeLinkKind): string => {
switch (kind) {
case 'channel':
return 'red'
case 'playlist':
return 'blue'
case 'video':
return 'green'
case 'handle':
return 'purple'
case 'unknown':
return 'grey'
}
}

const getYoutubeLinkIcon = (kind: YoutubeLinkKind): string => {
switch (kind) {
case 'channel':
return 'mdi-account-circle'
case 'playlist':
return 'mdi-playlist-play'
case 'video':
return 'mdi-play-circle'
case 'handle':
return 'mdi-at'
case 'unknown':
return 'mdi-help-circle'
}
}

const getFieldRules = (field: FlagField) => {
const rules: Array<(value: unknown) => boolean | string> = []

Expand Down Expand Up @@ -1398,127 +1508,127 @@
: []
}

const buildPayload = (): ScheduleUpdateSchema | null => {
const payload: Partial<ScheduleUpdateSchema> = {}

payload.name = editSchedule.value.name.trim()

// Basic properties
const basicProps: Array<keyof Schedule> = ['name', 'category', 'enabled', 'periodicity']
for (const prop of basicProps) {
if (editSchedule.value[prop] !== props.schedule[prop]) {
if (prop === 'name') {
payload.name = editSchedule.value[prop]
} else if (prop === 'category') {
payload.category = editSchedule.value[prop]
} else if (prop === 'enabled') {
payload.enabled = editSchedule.value[prop]
} else if (prop === 'periodicity') {
payload.periodicity = editSchedule.value[prop]
}
}
}

// Context with null/empty string equivalence
const originalContext = props.schedule.context
const editedContext = editSchedule.value.context
if (originalContext !== editedContext) {
// Consider null and empty string as equivalent
if (
!(
(originalContext === null || originalContext === '') &&
(editedContext === null || editedContext === '')
)
) {
// If edited context is null (because the user cleared the field), set it to an
// empty string as the API expects a string. Null values are considered as unset.
payload.context = editedContext || ''
}
}

// Comment
if (pendingComment.value?.trim()) {
payload.comment = pendingComment.value.trim()
}

// Tags
if (!stringArrayEqual(editSchedule.value.tags, props.schedule.tags)) {
payload.tags = editSchedule.value.tags
}

// Language
if (editSchedule.value.language.code !== props.schedule.language.code) {
payload.language = editSchedule.value.language.code
}

// Config properties
const configProps: Array<keyof ScheduleConfig> = ['warehouse_path', 'platform', 'monitor']
for (const prop of configProps) {
if (editSchedule.value.config[prop] !== props.schedule.config[prop]) {
if (prop === 'warehouse_path') {
payload.warehouse_path = editSchedule.value.config[prop]
} else if (prop === 'platform') {
payload.platform = editSchedule.value.config[prop]
} else if (prop === 'monitor') {
payload.monitor = editSchedule.value.config[prop]
}
}
}

// Offliner name
if (
editSchedule.value.config.offliner.offliner_id !== props.schedule.config.offliner.offliner_id
) {
payload.offliner = editSchedule.value.config.offliner.offliner_id as string
}

// Image
if (
editSchedule.value.config.image.name !== props.schedule.config.image.name ||
editSchedule.value.config.image.tag !== props.schedule.config.image.tag
) {
payload.image = editSchedule.value.config.image
}

// Resources
const resourceProps: Array<keyof Resources> = ['cpu', 'memory', 'disk', 'shm']
const resourcesChanged = resourceProps.some(
(prop) => editSchedule.value.config.resources[prop] !== props.schedule.config.resources[prop],
)
if (resourcesChanged) {
payload.resources = editSchedule.value.config.resources
}

// Artifacts
const artifacts_globs = processArtifactsGlobs(editSchedule.value.config.artifacts_globs_str)

if (!stringArrayEqual(artifacts_globs, props.schedule.config.artifacts_globs || [])) {
payload.artifacts_globs = artifacts_globs
}

// Notifications
if (!notificationsEqual(editSchedule.value.notification, props.schedule.notification)) {
payload.notification = cleanNotificationPayload(editSchedule.value.notification)
}

// Flags
const flags = cleanFlagsPayload(JSON.parse(JSON.stringify(editFlags.value)))
// remove the offliner_id from the flags as it is not used by the server and the schema is strict
// server-side
delete flags.offliner_id
if (Object.keys(flags).length > 0) {
payload.flags = flags
}

if (Object.keys(payload).length === 0) {
return null
}

// Version
payload.version = editSchedule.value.version

return payload as ScheduleUpdateSchema
}

Check notice on line 1631 in frontend-ui/src/components/ScheduleEditor.vue

View check run for this annotation

codefactor.io / CodeFactor

frontend-ui/src/components/ScheduleEditor.vue#L1511-L1631

Complex Method

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cleanFlagsPayload = (flags: Record<string, any>) => {
Expand Down Expand Up @@ -1552,6 +1662,15 @@

<style type="text/css" scoped>
.align-top {
vertical-align: top;
align-self: start;
}

.youtube-link-chip {
font-size: 0.8125rem;
height: 24px;
}

.youtube-link-chip:hover {
opacity: 0.85;
}
</style>
54 changes: 54 additions & 0 deletions frontend-ui/src/utils/youtube.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Classifies YouTube IDs and generates corresponding YouTube URLs.
*
* This is a heuristic-based approach since YouTube ID formats
* are not officially documented.
*
* References:
* - https://stackoverflow.com/questions/19795987/youtube-channel-and-playlist-id-prefixes
*/

export type YoutubeLinkKind = 'channel' | 'playlist' | 'video' | 'handle' | 'unknown'

export interface YoutubeLinkItem {
raw: string
kind: YoutubeLinkKind
url?: string
}

function classifyYoutubeId(raw: string): YoutubeLinkItem {
const value = raw.trim()
if (!value) return { raw, kind: 'unknown' }

// Handle YouTube handles (e.g., @channelname)
if (value.startsWith('@')) {
return { raw, kind: 'handle', url: `https://www.youtube.com/${value}` }
}

// Channel IDs typically start with "UC"
if (value.startsWith('UC')) {
return { raw, kind: 'channel', url: `https://www.youtube.com/channel/${value}` }
}

// Playlist IDs typically start with these prefixes
const playlistPrefixes = ['PL', 'RD', 'UU', 'LL', 'FL', 'WL']
if (playlistPrefixes.some((prefix) => value.startsWith(prefix))) {
return { raw, kind: 'playlist', url: `https://www.youtube.com/playlist?list=${value}` }
}

// Video IDs are typically 11 characters long
if (value.length === 11) {
return { raw, kind: 'video', url: `https://www.youtube.com/watch?v=${value}` }
}

return { raw, kind: 'unknown' }
}

export function buildYoutubeLinks(input: string): YoutubeLinkItem[] {
if (!input) return []
return input
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0)
.map((value) => classifyYoutubeId(value))
}