feat(desktop): select saved recipes when creating a schedule (#10892)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
@@ -1,46 +1,107 @@
|
||||
import React, { useState, useEffect, FormEvent, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useMemo, FormEvent, useCallback, useRef } from 'react';
|
||||
import type { ScheduledJobDto } from '@aaif/goose-sdk';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select } from '../ui/Select';
|
||||
import { CronPicker } from './CronPicker';
|
||||
import { Recipe, parseDeeplink, parseRecipeFromFile } from '../../recipe';
|
||||
import type { RecipeManifest } from '../../recipe';
|
||||
import { listSavedRecipes } from '../../recipe/recipe_management';
|
||||
import ClockIcon from '../../assets/clock-icon.svg';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
editSchedule: { id: 'scheduleModal.editSchedule', defaultMessage: 'Edit Schedule' },
|
||||
createNewSchedule: { id: 'scheduleModal.createNewSchedule', defaultMessage: 'Create New Schedule' },
|
||||
createNewSchedule: {
|
||||
id: 'scheduleModal.createNewSchedule',
|
||||
defaultMessage: 'Create New Schedule',
|
||||
},
|
||||
nameLabel: { id: 'scheduleModal.nameLabel', defaultMessage: 'Name:' },
|
||||
namePlaceholder: { id: 'scheduleModal.namePlaceholder', defaultMessage: 'e.g., daily-summary-job' },
|
||||
namePlaceholder: {
|
||||
id: 'scheduleModal.namePlaceholder',
|
||||
defaultMessage: 'e.g., daily-summary-job',
|
||||
},
|
||||
sourceLabel: { id: 'scheduleModal.sourceLabel', defaultMessage: 'Source:' },
|
||||
yaml: { id: 'scheduleModal.yaml', defaultMessage: 'YAML' },
|
||||
deepLink: { id: 'scheduleModal.deepLink', defaultMessage: 'Deep link' },
|
||||
savedRecipes: { id: 'scheduleModal.savedRecipes', defaultMessage: 'Saved recipes' },
|
||||
browseYaml: { id: 'scheduleModal.browseYaml', defaultMessage: 'Browse for YAML file...' },
|
||||
selected: { id: 'scheduleModal.selected', defaultMessage: 'Selected: {path}' },
|
||||
deepLinkPlaceholder: { id: 'scheduleModal.deepLinkPlaceholder', defaultMessage: 'Paste goose://recipe link here...' },
|
||||
deepLinkPlaceholder: {
|
||||
id: 'scheduleModal.deepLinkPlaceholder',
|
||||
defaultMessage: 'Paste goose://recipe link here...',
|
||||
},
|
||||
selectRecipePlaceholder: {
|
||||
id: 'scheduleModal.selectRecipePlaceholder',
|
||||
defaultMessage: 'Select a saved recipe',
|
||||
},
|
||||
alreadyScheduledOption: {
|
||||
id: 'scheduleModal.alreadyScheduledOption',
|
||||
defaultMessage: '{title} (already scheduled)',
|
||||
},
|
||||
recipeAlreadyScheduled: {
|
||||
id: 'scheduleModal.recipeAlreadyScheduled',
|
||||
defaultMessage: 'This recipe already has a schedule. Edit it from the Recipes list.',
|
||||
},
|
||||
loadingRecipes: { id: 'scheduleModal.loadingRecipes', defaultMessage: 'Loading recipes...' },
|
||||
noSavedRecipes: { id: 'scheduleModal.noSavedRecipes', defaultMessage: 'No saved recipes found.' },
|
||||
failedLoadRecipes: {
|
||||
id: 'scheduleModal.failedLoadRecipes',
|
||||
defaultMessage: 'Failed to load recipes.',
|
||||
},
|
||||
recipeParsed: { id: 'scheduleModal.recipeParsed', defaultMessage: 'Recipe parsed successfully' },
|
||||
recipeTitle: { id: 'scheduleModal.recipeTitle', defaultMessage: 'Title: {title}' },
|
||||
recipeDescription: { id: 'scheduleModal.recipeDescription', defaultMessage: 'Description: {description}' },
|
||||
recipeDescription: {
|
||||
id: 'scheduleModal.recipeDescription',
|
||||
defaultMessage: 'Description: {description}',
|
||||
},
|
||||
scheduleLabel: { id: 'scheduleModal.scheduleLabel', defaultMessage: 'Schedule:' },
|
||||
cancel: { id: 'scheduleModal.cancel', defaultMessage: 'Cancel' },
|
||||
updating: { id: 'scheduleModal.updating', defaultMessage: 'Updating...' },
|
||||
creating: { id: 'scheduleModal.creating', defaultMessage: 'Creating...' },
|
||||
updateSchedule: { id: 'scheduleModal.updateSchedule', defaultMessage: 'Update Schedule' },
|
||||
createSchedule: { id: 'scheduleModal.createSchedule', defaultMessage: 'Create Schedule' },
|
||||
invalidDeepLink: { id: 'scheduleModal.invalidDeepLink', defaultMessage: 'Invalid deep link. Please use a goose://recipe link.' },
|
||||
failedReadFile: { id: 'scheduleModal.failedReadFile', defaultMessage: 'Failed to read the selected file.' },
|
||||
failedParseRecipe: { id: 'scheduleModal.failedParseRecipe', defaultMessage: 'Failed to parse recipe from file.' },
|
||||
invalidFileType: { id: 'scheduleModal.invalidFileType', defaultMessage: 'Invalid file type: Please select a YAML file (.yaml or .yml)' },
|
||||
scheduleIdRequired: { id: 'scheduleModal.scheduleIdRequired', defaultMessage: 'Schedule ID is required.' },
|
||||
provideValidRecipe: { id: 'scheduleModal.provideValidRecipe', defaultMessage: 'Please provide a valid recipe source.' },
|
||||
invalidDeepLink: {
|
||||
id: 'scheduleModal.invalidDeepLink',
|
||||
defaultMessage: 'Invalid deep link. Please use a goose://recipe link.',
|
||||
},
|
||||
failedReadFile: {
|
||||
id: 'scheduleModal.failedReadFile',
|
||||
defaultMessage: 'Failed to read the selected file.',
|
||||
},
|
||||
failedParseRecipe: {
|
||||
id: 'scheduleModal.failedParseRecipe',
|
||||
defaultMessage: 'Failed to parse recipe from file.',
|
||||
},
|
||||
invalidFileType: {
|
||||
id: 'scheduleModal.invalidFileType',
|
||||
defaultMessage: 'Invalid file type: Please select a YAML file (.yaml or .yml)',
|
||||
},
|
||||
scheduleIdRequired: {
|
||||
id: 'scheduleModal.scheduleIdRequired',
|
||||
defaultMessage: 'Schedule ID is required.',
|
||||
},
|
||||
provideValidRecipe: {
|
||||
id: 'scheduleModal.provideValidRecipe',
|
||||
defaultMessage: 'Please provide a valid recipe source.',
|
||||
},
|
||||
});
|
||||
|
||||
export interface NewSchedulePayload {
|
||||
id: string;
|
||||
recipe: Recipe;
|
||||
cron: string;
|
||||
}
|
||||
export type SourceType = 'file' | 'deeplink' | 'saved';
|
||||
|
||||
export type NewSchedulePayload =
|
||||
| {
|
||||
sourceType: 'file' | 'deeplink';
|
||||
id: string;
|
||||
recipe: Recipe;
|
||||
cron: string;
|
||||
}
|
||||
| {
|
||||
sourceType: 'saved';
|
||||
recipeId: string;
|
||||
cron: string;
|
||||
};
|
||||
|
||||
interface ScheduleModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -52,8 +113,6 @@ interface ScheduleModalProps {
|
||||
initialDeepLink: string | null;
|
||||
}
|
||||
|
||||
type SourceType = 'file' | 'deeplink';
|
||||
|
||||
const modalLabelClassName = 'block text-sm font-medium text-text-primary mb-1';
|
||||
|
||||
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
@@ -76,6 +135,16 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
const [cronExpression, setCronExpression] = useState<string>('0 0 14 * * *');
|
||||
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [savedRecipes, setSavedRecipes] = useState<RecipeManifest[]>([]);
|
||||
const [savedRecipesLoading, setSavedRecipesLoading] = useState(false);
|
||||
const [savedRecipesError, setSavedRecipesError] = useState<string | null>(null);
|
||||
const [selectedSavedRecipeId, setSelectedSavedRecipeId] = useState<string | null>(null);
|
||||
const sourceRequestIdRef = useRef(0);
|
||||
|
||||
const bumpSourceRequest = () => {
|
||||
sourceRequestIdRef.current += 1;
|
||||
return sourceRequestIdRef.current;
|
||||
};
|
||||
|
||||
const setScheduleIdFromTitle = (title: string) => {
|
||||
const cleanId = title
|
||||
@@ -85,26 +154,76 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
setScheduleId(cleanId);
|
||||
};
|
||||
|
||||
const handleDeepLinkChange = useCallback(async (value: string) => {
|
||||
setDeepLinkInput(value);
|
||||
const clearRecipeSourceState = useCallback(() => {
|
||||
setParsedRecipe(null);
|
||||
setRecipeSourcePath('');
|
||||
setDeepLinkInput('');
|
||||
setSelectedSavedRecipeId(null);
|
||||
setInternalValidationError(null);
|
||||
}, []);
|
||||
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const recipe = await parseDeeplink(value.trim());
|
||||
if (!recipe) throw new Error();
|
||||
setParsedRecipe(recipe);
|
||||
if (recipe.title) {
|
||||
setScheduleIdFromTitle(recipe.title);
|
||||
}
|
||||
} catch {
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(intl.formatMessage(i18n.invalidDeepLink));
|
||||
const handleSourceTypeChange = useCallback(
|
||||
(next: SourceType) => {
|
||||
if (next === sourceType) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
}
|
||||
}, [intl]);
|
||||
bumpSourceRequest();
|
||||
clearRecipeSourceState();
|
||||
setSourceType(next);
|
||||
},
|
||||
[clearRecipeSourceState, sourceType]
|
||||
);
|
||||
|
||||
const handleSelectSavedRecipe = useCallback(
|
||||
(manifest: RecipeManifest | null) => {
|
||||
bumpSourceRequest();
|
||||
if (manifest?.schedule_cron) {
|
||||
setSelectedSavedRecipeId(null);
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(intl.formatMessage(i18n.recipeAlreadyScheduled));
|
||||
return;
|
||||
}
|
||||
setSelectedSavedRecipeId(manifest?.id ?? null);
|
||||
setInternalValidationError(null);
|
||||
if (manifest) {
|
||||
setParsedRecipe(manifest.recipe);
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
}
|
||||
},
|
||||
[intl]
|
||||
);
|
||||
|
||||
const handleDeepLinkChange = useCallback(
|
||||
async (value: string) => {
|
||||
const requestId = bumpSourceRequest();
|
||||
setDeepLinkInput(value);
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const recipe = await parseDeeplink(value.trim());
|
||||
if (requestId !== sourceRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!recipe) throw new Error();
|
||||
setParsedRecipe(recipe);
|
||||
if (recipe.title) {
|
||||
setScheduleIdFromTitle(recipe.title);
|
||||
}
|
||||
} catch {
|
||||
if (requestId !== sourceRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(intl.formatMessage(i18n.invalidDeepLink));
|
||||
}
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
}
|
||||
},
|
||||
[intl]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -113,12 +232,17 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
setScheduleId(schedule.id);
|
||||
setCronExpression(schedule.cron);
|
||||
} else {
|
||||
bumpSourceRequest();
|
||||
setScheduleId('');
|
||||
setSourceType('file');
|
||||
setRecipeSourcePath('');
|
||||
setDeepLinkInput('');
|
||||
setParsedRecipe(null);
|
||||
setCronExpression('0 0 14 * * *');
|
||||
setSavedRecipes([]);
|
||||
setSavedRecipesLoading(false);
|
||||
setSavedRecipesError(null);
|
||||
setSelectedSavedRecipeId(null);
|
||||
if (initialDeepLink) {
|
||||
setSourceType('deeplink');
|
||||
handleDeepLinkChange(initialDeepLink);
|
||||
@@ -127,18 +251,61 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
}
|
||||
}, [isOpen, schedule, initialDeepLink, handleDeepLinkChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceType !== 'saved') {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSavedRecipesLoading(true);
|
||||
setSavedRecipesError(null);
|
||||
listSavedRecipes()
|
||||
.then((recipes) => {
|
||||
if (!cancelled) {
|
||||
setSavedRecipes(recipes);
|
||||
setSavedRecipesLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSavedRecipesError(intl.formatMessage(i18n.failedLoadRecipes));
|
||||
setSavedRecipesLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sourceType, intl]);
|
||||
|
||||
const savedRecipeOptions = useMemo(
|
||||
() =>
|
||||
savedRecipes.map((m) => ({
|
||||
value: m.id,
|
||||
label: m.schedule_cron
|
||||
? intl.formatMessage(i18n.alreadyScheduledOption, { title: m.recipe.title || m.id })
|
||||
: m.recipe.title || m.id,
|
||||
})),
|
||||
[savedRecipes, intl]
|
||||
);
|
||||
|
||||
const handleBrowseFile = async () => {
|
||||
const fileResponse = await window.electron.selectRecipeFile();
|
||||
if (fileResponse) {
|
||||
if (fileResponse.filePath.endsWith('.yaml') || fileResponse.filePath.endsWith('.yml')) {
|
||||
const requestId = bumpSourceRequest();
|
||||
setRecipeSourcePath(fileResponse.filePath);
|
||||
setInternalValidationError(null);
|
||||
|
||||
try {
|
||||
if (requestId !== sourceRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!fileResponse.found || fileResponse.error) {
|
||||
throw new Error(intl.formatMessage(i18n.failedReadFile));
|
||||
}
|
||||
const recipe = await parseRecipeFromFile(fileResponse.file);
|
||||
if (requestId !== sourceRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!recipe) {
|
||||
throw new Error(intl.formatMessage(i18n.failedParseRecipe));
|
||||
}
|
||||
@@ -147,6 +314,9 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
setScheduleIdFromTitle(recipe.title);
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== sourceRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(
|
||||
e instanceof Error ? e.message : intl.formatMessage(i18n.failedParseRecipe)
|
||||
@@ -167,6 +337,24 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceType === 'saved') {
|
||||
const selected = savedRecipes.find((m) => m.id === selectedSavedRecipeId);
|
||||
if (!selected) {
|
||||
setInternalValidationError(intl.formatMessage(i18n.provideValidRecipe));
|
||||
return;
|
||||
}
|
||||
if (selected.schedule_cron) {
|
||||
setInternalValidationError(intl.formatMessage(i18n.recipeAlreadyScheduled));
|
||||
return;
|
||||
}
|
||||
await onSubmit({
|
||||
sourceType: 'saved',
|
||||
recipeId: selected.id,
|
||||
cron: cronExpression,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduleId.trim()) {
|
||||
setInternalValidationError(intl.formatMessage(i18n.scheduleIdRequired));
|
||||
return;
|
||||
@@ -177,17 +365,40 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const newSchedulePayload: NewSchedulePayload = {
|
||||
if (sourceType === 'file' && !recipeSourcePath) {
|
||||
setInternalValidationError(intl.formatMessage(i18n.provideValidRecipe));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceType === 'deeplink' && !deepLinkInput.trim()) {
|
||||
setInternalValidationError(intl.formatMessage(i18n.provideValidRecipe));
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit({
|
||||
sourceType,
|
||||
id: scheduleId.trim(),
|
||||
recipe: parsedRecipe,
|
||||
cron: cronExpression,
|
||||
};
|
||||
|
||||
await onSubmit(newSchedulePayload);
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const recipeParsedPreview = parsedRecipe ? (
|
||||
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
|
||||
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
|
||||
✓ {intl.formatMessage(i18n.recipeParsed)}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{intl.formatMessage(i18n.recipeTitle, { title: parsedRecipe.title })}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{intl.formatMessage(i18n.recipeDescription, { description: parsedRecipe.description })}
|
||||
</p>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md bg-background-primary shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
@@ -196,7 +407,9 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
<img src={ClockIcon} alt="Clock" className="w-8 h-8" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-base font-semibold text-text-primary">
|
||||
{isEditMode ? intl.formatMessage(i18n.editSchedule) : intl.formatMessage(i18n.createNewSchedule)}
|
||||
{isEditMode
|
||||
? intl.formatMessage(i18n.editSchedule)
|
||||
: intl.formatMessage(i18n.createNewSchedule)}
|
||||
</h2>
|
||||
{isEditMode && <p className="text-sm text-text-secondary">{schedule.id}</p>}
|
||||
</div>
|
||||
@@ -221,29 +434,31 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
|
||||
{intl.formatMessage(i18n.nameLabel)} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="scheduleId-modal"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
placeholder={intl.formatMessage(i18n.namePlaceholder)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{sourceType !== 'saved' && (
|
||||
<div>
|
||||
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
|
||||
{intl.formatMessage(i18n.nameLabel)} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="scheduleId-modal"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
placeholder={intl.formatMessage(i18n.namePlaceholder)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={modalLabelClassName}>
|
||||
{intl.formatMessage(i18n.sourceLabel)} <span className="text-red-500">*</span>
|
||||
{intl.formatMessage(i18n.sourceLabel)} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('file')}
|
||||
onClick={() => handleSourceTypeChange('file')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'file'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
@@ -254,7 +469,7 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('deeplink')}
|
||||
onClick={() => handleSourceTypeChange('deeplink')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'deeplink'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
@@ -263,6 +478,17 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
>
|
||||
{intl.formatMessage(i18n.deepLink)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSourceTypeChange('saved')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'saved'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{intl.formatMessage(i18n.savedRecipes)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sourceType === 'file' && (
|
||||
@@ -292,19 +518,39 @@ export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
placeholder={intl.formatMessage(i18n.deepLinkPlaceholder)}
|
||||
className="rounded-full"
|
||||
/>
|
||||
{parsedRecipe && (
|
||||
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
|
||||
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
|
||||
✓ {intl.formatMessage(i18n.recipeParsed)}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{intl.formatMessage(i18n.recipeTitle, { title: parsedRecipe.title })}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
{intl.formatMessage(i18n.recipeDescription, { description: parsedRecipe.description })}
|
||||
</p>
|
||||
</div>
|
||||
{recipeParsedPreview}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceType === 'saved' && (
|
||||
<div data-testid="saved-recipe-picker">
|
||||
{savedRecipesError ? (
|
||||
<p className="text-sm text-text-danger">{savedRecipesError}</p>
|
||||
) : savedRecipes.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{savedRecipesLoading
|
||||
? intl.formatMessage(i18n.loadingRecipes)
|
||||
: intl.formatMessage(i18n.noSavedRecipes)}
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
options={savedRecipeOptions}
|
||||
value={
|
||||
savedRecipeOptions.find((o) => o.value === selectedSavedRecipeId) ??
|
||||
null
|
||||
}
|
||||
onChange={(option) => {
|
||||
const selectedId = (option as { value?: string } | null)?.value ?? null;
|
||||
handleSelectSavedRecipe(
|
||||
selectedId
|
||||
? (savedRecipes.find((m) => m.id === selectedId) ?? null)
|
||||
: null
|
||||
);
|
||||
}}
|
||||
placeholder={intl.formatMessage(i18n.selectRecipePlaceholder)}
|
||||
/>
|
||||
)}
|
||||
{recipeParsedPreview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
acpKillRunningJob,
|
||||
acpInspectRunningJob,
|
||||
} from '../../acp/schedules';
|
||||
import { scheduleRecipe } from '../../recipe/recipe_management';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -40,25 +41,41 @@ const i18n = defineMessages({
|
||||
refreshing: { id: 'schedulesView.refreshing', defaultMessage: 'Refreshing...' },
|
||||
refresh: { id: 'schedulesView.refresh', defaultMessage: 'Refresh' },
|
||||
createSchedule: { id: 'schedulesView.createSchedule', defaultMessage: 'Create Schedule' },
|
||||
description: { id: 'schedulesView.description', defaultMessage: 'Create and manage scheduled tasks to run recipes automatically at specified times.' },
|
||||
description: {
|
||||
id: 'schedulesView.description',
|
||||
defaultMessage:
|
||||
'Create and manage scheduled tasks to run recipes automatically at specified times.',
|
||||
},
|
||||
errorPrefix: { id: 'schedulesView.errorPrefix', defaultMessage: 'Error: {error}' },
|
||||
noSchedules: { id: 'schedulesView.noSchedules', defaultMessage: 'No schedules yet' },
|
||||
scheduleUpdated: { id: 'schedulesView.scheduleUpdated', defaultMessage: 'Schedule Updated' },
|
||||
scheduleUpdatedMsg: { id: 'schedulesView.scheduleUpdatedMsg', defaultMessage: 'Successfully updated schedule "{id}"' },
|
||||
scheduleUpdatedMsg: {
|
||||
id: 'schedulesView.scheduleUpdatedMsg',
|
||||
defaultMessage: 'Successfully updated schedule "{id}"',
|
||||
},
|
||||
confirmDelete: {
|
||||
id: 'schedulesView.confirmDelete',
|
||||
defaultMessage: 'Remove schedule "{id}"? The recipe will be kept.',
|
||||
},
|
||||
schedulePaused: { id: 'schedulesView.schedulePaused', defaultMessage: 'Schedule Paused' },
|
||||
schedulePausedMsg: { id: 'schedulesView.schedulePausedMsg', defaultMessage: 'Successfully paused schedule "{id}"' },
|
||||
schedulePausedMsg: {
|
||||
id: 'schedulesView.schedulePausedMsg',
|
||||
defaultMessage: 'Successfully paused schedule "{id}"',
|
||||
},
|
||||
pauseError: { id: 'schedulesView.pauseError', defaultMessage: 'Pause Schedule Error' },
|
||||
scheduleUnpaused: { id: 'schedulesView.scheduleUnpaused', defaultMessage: 'Schedule Unpaused' },
|
||||
scheduleUnpausedMsg: { id: 'schedulesView.scheduleUnpausedMsg', defaultMessage: 'Successfully unpaused schedule "{id}"' },
|
||||
scheduleUnpausedMsg: {
|
||||
id: 'schedulesView.scheduleUnpausedMsg',
|
||||
defaultMessage: 'Successfully unpaused schedule "{id}"',
|
||||
},
|
||||
unpauseError: { id: 'schedulesView.unpauseError', defaultMessage: 'Unpause Schedule Error' },
|
||||
jobKilled: { id: 'schedulesView.jobKilled', defaultMessage: 'Job Killed' },
|
||||
killError: { id: 'schedulesView.killError', defaultMessage: 'Kill Job Error' },
|
||||
jobInspection: { id: 'schedulesView.jobInspection', defaultMessage: 'Job Inspection' },
|
||||
inspectNoInfo: { id: 'schedulesView.inspectNoInfo', defaultMessage: 'No detailed information available for this job' },
|
||||
inspectNoInfo: {
|
||||
id: 'schedulesView.inspectNoInfo',
|
||||
defaultMessage: 'No detailed information available for this job',
|
||||
},
|
||||
inspectError: { id: 'schedulesView.inspectError', defaultMessage: 'Inspect Job Error' },
|
||||
});
|
||||
|
||||
@@ -296,9 +313,16 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
});
|
||||
} else {
|
||||
const newPayload = payload as NewSchedulePayload;
|
||||
await acpCreateSchedule(newPayload);
|
||||
const sourceType = pendingDeepLink ? 'deeplink' : 'file';
|
||||
trackScheduleCreated(sourceType, true);
|
||||
if (newPayload.sourceType === 'saved') {
|
||||
await scheduleRecipe(newPayload.recipeId, newPayload.cron);
|
||||
} else {
|
||||
await acpCreateSchedule({
|
||||
id: newPayload.id,
|
||||
recipe: newPayload.recipe,
|
||||
cron: newPayload.cron,
|
||||
});
|
||||
}
|
||||
trackScheduleCreated(newPayload.sourceType, true);
|
||||
}
|
||||
await fetchSchedules();
|
||||
setIsModalOpen(false);
|
||||
@@ -309,8 +333,13 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
setSubmitApiError(errorMsg);
|
||||
|
||||
if (!editingSchedule) {
|
||||
const sourceType = pendingDeepLink ? 'deeplink' : 'file';
|
||||
trackScheduleCreated(sourceType, false, getErrorType(error));
|
||||
const failedSourceType =
|
||||
typeof payload === 'object' && payload && 'sourceType' in payload
|
||||
? payload.sourceType
|
||||
: pendingDeepLink
|
||||
? 'deeplink'
|
||||
: 'file';
|
||||
trackScheduleCreated(failedSourceType, false, getErrorType(error));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
@@ -493,7 +522,9 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? intl.formatMessage(i18n.refreshing) : intl.formatMessage(i18n.refresh)}
|
||||
{isRefreshing
|
||||
? intl.formatMessage(i18n.refreshing)
|
||||
: intl.formatMessage(i18n.refresh)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -519,7 +550,9 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
<div className="h-full relative">
|
||||
{apiError && (
|
||||
<div className="mb-4 p-4 bg-background-danger border border-border-danger rounded-md">
|
||||
<p className="text-text-danger text-sm">{intl.formatMessage(i18n.errorPrefix, { error: apiError })}</p>
|
||||
<p className="text-text-danger text-sm">
|
||||
{intl.formatMessage(i18n.errorPrefix, { error: apiError })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, type RenderOptions, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, type RenderOptions, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ScheduledJobDto } from '@aaif/goose-sdk';
|
||||
import { ScheduleModal } from '../ScheduleModal';
|
||||
import { IntlTestWrapper } from '../../../i18n/test-utils';
|
||||
import { listSavedRecipes } from '../../../recipe/recipe_management';
|
||||
import type { RecipeManifest } from '../../../recipe';
|
||||
|
||||
vi.mock('../../../recipe/recipe_management', () => ({
|
||||
listSavedRecipes: vi.fn(),
|
||||
getStorageDirectory: vi.fn(() => ''),
|
||||
}));
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
@@ -21,7 +28,27 @@ const baseProps = {
|
||||
initialDeepLink: null,
|
||||
};
|
||||
|
||||
const savedRecipeManifest = {
|
||||
id: 'my-recipe',
|
||||
recipe: { title: 'My Recipe', description: 'A test recipe' },
|
||||
file_path: '/recipes/my-recipe.yaml',
|
||||
last_modified: '',
|
||||
} as unknown as RecipeManifest;
|
||||
|
||||
const alreadyScheduledManifest = {
|
||||
id: 'already-scheduled',
|
||||
recipe: { title: 'Already Scheduled', description: 'Has a cron' },
|
||||
file_path: '/recipes/already-scheduled.yaml',
|
||||
last_modified: '',
|
||||
schedule_cron: '0 0 9 * * *',
|
||||
} as unknown as RecipeManifest;
|
||||
|
||||
describe('ScheduleModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listSavedRecipes).mockResolvedValue([savedRecipeManifest]);
|
||||
});
|
||||
|
||||
it('preserves the form when the recipe picker is cancelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const selectRecipeFile = vi.fn().mockResolvedValue(null);
|
||||
@@ -50,4 +77,119 @@ describe('ScheduleModal', () => {
|
||||
expect(screen.getByText('Edit Schedule')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Please provide a valid recipe source.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads saved recipes into a picker and creates a schedule from the selected one', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listSavedRecipes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const picker = within(screen.getByTestId('saved-recipe-picker'));
|
||||
await user.click(await picker.findByRole('combobox'));
|
||||
const option = await picker.findByRole('option', { name: 'My Recipe' });
|
||||
await user.click(option);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Title: My Recipe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const createButton = screen.getByRole('button', { name: 'Create Schedule' });
|
||||
expect(createButton).not.toBeDisabled();
|
||||
await user.click(createButton);
|
||||
|
||||
expect(screen.queryByLabelText(/name/i)).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(baseProps.onSubmit).toHaveBeenCalledWith({
|
||||
sourceType: 'saved',
|
||||
recipeId: 'my-recipe',
|
||||
cron: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no saved recipes', async () => {
|
||||
vi.mocked(listSavedRecipes).mockResolvedValue([]);
|
||||
const user = userEvent.setup();
|
||||
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No saved recipes found.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces recipe list failures instead of an empty state', async () => {
|
||||
vi.mocked(listSavedRecipes).mockRejectedValue(new Error('ACP down'));
|
||||
const user = userEvent.setup();
|
||||
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load recipes.')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('No saved recipes found.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks creating a schedule from an already-scheduled recipe', async () => {
|
||||
vi.mocked(listSavedRecipes).mockResolvedValue([alreadyScheduledManifest]);
|
||||
const user = userEvent.setup();
|
||||
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
|
||||
await waitFor(() => {
|
||||
expect(listSavedRecipes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const picker = within(screen.getByTestId('saved-recipe-picker'));
|
||||
await user.click(await picker.findByRole('combobox'));
|
||||
await user.click(
|
||||
await picker.findByRole('option', { name: /Already Scheduled \(already scheduled\)/i })
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('This recipe already has a schedule. Edit it from the Recipes list.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(baseProps.onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Schedule' }));
|
||||
expect(baseProps.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a previously parsed recipe when switching source tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithIntl(<ScheduleModal {...baseProps} isOpen schedule={null} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Saved recipes' }));
|
||||
await waitFor(() => {
|
||||
expect(listSavedRecipes).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const picker = within(screen.getByTestId('saved-recipe-picker'));
|
||||
await user.click(await picker.findByRole('combobox'));
|
||||
await user.click(await picker.findByRole('option', { name: 'My Recipe' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Title: My Recipe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'YAML' }));
|
||||
expect(screen.queryByText('Title: My Recipe')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText(/name/i), 'stale-job');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Schedule' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please provide a valid recipe source.')).toBeInTheDocument();
|
||||
});
|
||||
expect(baseProps.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Zeitplan-ID wird angezeigt: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (bereits geplant)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Nach YAML-Datei suchen..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Zeitplan bearbeiten"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Rezepte konnten nicht geladen werden."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Rezept konnte nicht aus der Datei gelesen werden."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Ungültiger Dateityp: Bitte wählen Sie eine YAML-Datei (.yaml oder .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Rezepte werden geladen..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Name:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "z. B. daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Keine gespeicherten Rezepte gefunden."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Bitte geben Sie eine gültige Rezeptquelle an."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Dieses Rezept hat bereits einen Zeitplan. Bearbeiten Sie ihn in der Rezeptliste."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Beschreibung: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Titel: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Gespeicherte Rezepte"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "Zeitplan-ID ist erforderlich."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Zeitplan:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Ein gespeichertes Rezept auswählen"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Ausgewählt: {path}"
|
||||
},
|
||||
|
||||
@@ -3455,6 +3455,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Viewing Schedule ID: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (already scheduled)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Browse for YAML file..."
|
||||
},
|
||||
@@ -3479,6 +3482,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Edit Schedule"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Failed to load recipes."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Failed to parse recipe from file."
|
||||
},
|
||||
@@ -3491,15 +3497,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Invalid file type: Please select a YAML file (.yaml or .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Loading recipes..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Name:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "e.g., daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "No saved recipes found."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Please provide a valid recipe source."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "This recipe already has a schedule. Edit it from the Recipes list."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Description: {description}"
|
||||
},
|
||||
@@ -3509,12 +3524,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Title: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Saved recipes"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "Schedule ID is required."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Schedule:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Select a saved recipe"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Selected: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Viendo el ID de programación: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (ya programada)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Buscar archivo YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Editar programación"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Error al cargar las recetas."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "No se pudo analizar la receta del archivo."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Tipo de archivo no válido: selecciona un archivo YAML (.yaml o .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Cargando recetas..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Nombre:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "p. ej., resumen-diario"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "No se encontraron recetas guardadas."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Proporciona un origen de receta válido."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Esta receta ya tiene una programación. Edítala desde la lista de Recetas."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Descripción: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Título: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Recetas guardadas"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "Se requiere el ID de la programación."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Programación:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Selecciona una receta guardada"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Seleccionado: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Affichage de l'ID de planification : {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (déjà planifiée)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Parcourir pour un fichier YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Modifier la planification"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Échec du chargement des recettes."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Échec de l'analyse de la recette à partir du fichier."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Type de fichier invalide : veuillez sélectionner un fichier YAML (.yaml ou .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Chargement des recettes..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Nom :"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "p. ex. daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Aucune recette enregistrée trouvée."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Veuillez fournir une source de recette valide."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Cette recette a déjà une planification. Modifiez-la depuis la liste des Recettes."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Description : {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Titre : {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Recettes enregistrées"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "L'ID de planification est requis."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Planification :"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Sélectionner une recette enregistrée"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Sélectionné : {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "शेड्यूल आईडी देखना: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (पहले से शेड्यूल)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "YAML फ़ाइल ब्राउज़ करें..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "शेड्यूल संपादित करें"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "रेसिपी लोड करने में विफल।"
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "फ़ाइल से रेसिपी पार्स करने में विफल।"
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "अमान्य फ़ाइल प्रकार: कृपया YAML file (.yaml या .yml) चुनें"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "रेसिपी लोड हो रही हैं..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "नाम:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "उदाहरण के लिए, दैनिक-सारांश-कार्य"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "कोई सहेजी गई रेसिपी नहीं मिली।"
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "कृपया एक मान्य रेसिपी स्रोत दें।"
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "इस रेसिपी का पहले से शेड्यूल है। रेसिपी सूची से इसे संपादित करें।"
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "विवरण: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "शीर्षक: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "सहेजी गई रेसिपी"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "शेड्यूल आईडी आवश्यक है."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "अनुसूची:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "एक सहेजी गई रेसिपी चुनें"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "चयनित: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Melihat ID Jadwal: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (sudah dijadwalkan)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Telusuri berkas YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Edit Jadwal"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Gagal memuat resep."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Gagal mengurai resep dari berkas."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Jenis berkas tidak valid: Harap pilih berkas YAML (.yaml atau .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Memuat resep..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Nama:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "mis., daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Tidak ada resep tersimpan ditemukan."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Harap berikan sumber resep yang valid."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Resep ini sudah memiliki jadwal. Edit dari daftar Resep."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Deskripsi: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Judul: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Resep tersimpan"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "ID jadwal diperlukan."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Jadwal:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Pilih resep tersimpan"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Dipilih: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Visualizzazione ID pianificazione: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (già pianificata)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Sfoglia file YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Modifica pianificazione"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Caricamento delle ricette non riuscito."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Impossibile analizzare la ricetta dal file."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Tipo di file non valido: seleziona un file YAML (.yaml o .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Caricamento ricette..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Nome:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "es. daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Nessuna ricetta salvata trovata."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Fornisci un'origine ricetta valida."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Questa ricetta ha già una pianificazione. Modificala dall'elenco Ricette."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Descrizione: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Titolo: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Ricette salvate"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "L'ID pianificazione è obbligatorio."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Pianificazione:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Seleziona una ricetta salvata"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Selezionato: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "表示中のスケジュールID: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title}(スケジュール済み)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "YAMLファイルを選択..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "スケジュールを編集"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "レシピの読み込みに失敗しました。"
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "ファイルからレシピを解析できませんでした。"
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "無効なファイル形式です。YAMLファイル(.yaml または .yml)を選択してください"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "レシピを読み込み中..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "名前:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "例: daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "保存したレシピが見つかりません。"
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "有効なレシピソースを指定してください。"
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "このレシピには既にスケジュールがあります。レシピ一覧から編集してください。"
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "説明: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "タイトル: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "保存したレシピ"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "スケジュールIDは必須です。"
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "スケジュール:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "保存したレシピを選択"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "選択済み: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "일정 ID 보기: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (이미 예약됨)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "YAML 파일 찾아보기..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "일정 편집"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "레시피를 불러오지 못했습니다."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "파일에서 레시피를 구문 분석하지 못했습니다."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "잘못된 파일 형식: YAML 파일(.yaml 또는 .yml)을 선택하세요."
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "레시피 불러오는 중..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "이름:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "예: daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "저장된 레시피가 없습니다."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "유효한 레시피 소스를 입력하세요."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "이 레시피에는 이미 일정이 있습니다. 레시피 목록에서 편집하세요."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "설명: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "제목: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "저장된 레시피"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "일정 ID가 필요합니다."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "일정:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "저장된 레시피 선택"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "선택됨: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Melihat ID Jadual: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (sudah dijadualkan)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Layari fail YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Edit Jadual"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Gagal memuatkan resipi."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Gagal menghuraikan resipi daripada fail."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Jenis fail tidak sah: Sila pilih fail YAML (.yaml atau .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Memuatkan resipi..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Nama:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "cth., daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Tiada resipi tersimpan dijumpai."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Sila sediakan sumber resipi yang sah."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Resipi ini sudah mempunyai jadual. Edit dari senarai Resipi."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Penerangan: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Tajuk: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Resipi tersimpan"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "ID jadual diperlukan."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Jadual:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Pilih resipi tersimpan"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Dipilih: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "A visualizar o ID do agendamento: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (já agendada)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Procurar ficheiro YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Editar agendamento"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Falha ao carregar receitas."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Falha ao analisar a receita a partir do ficheiro."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Tipo de ficheiro inválido: selecione um ficheiro YAML (.yaml ou .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Carregando receitas..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Nome:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "p. ex., tarefa-resumo-diario"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Nenhuma receita salva encontrada."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Forneça uma origem de receita válida."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Esta receita já tem um agendamento. Edite-o na lista de Receitas."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Descrição: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Título: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Receitas salvas"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "O ID do agendamento é obrigatório."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Agendamento:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Selecionar uma receita salva"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Selecionado: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Просмотр ID расписания: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (уже запланирован)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Выбрать YAML-файл..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Изменить расписание"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Не удалось загрузить рецепты."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Не удалось разобрать рецепт из файла."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Неверный тип файла: выберите YAML-файл (.yaml или .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Загрузка рецептов..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Имя:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "например, daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Сохранённые рецепты не найдены."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Укажите корректный источник рецепта."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "У этого рецепта уже есть расписание. Измените его в списке рецептов."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Описание: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Название: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Сохранённые рецепты"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "Требуется ID расписания."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Расписание:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Выберите сохранённый рецепт"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Выбрано: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Program Kimliğini Görüntüleme: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (zaten zamanlanmış)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "YAML dosyasına göz atın..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Programı Düzenle"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Tarifeler yüklenemedi."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Dosyadan tarif ayrıştırılamadı."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Geçersiz dosya türü: Lütfen bir YAML dosyası seçin (.yaml veya.yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Tarifeler yükleniyor..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "İsim:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "örneğin günlük özet işi"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Kayıtlı tarife bulunamadı."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Lütfen geçerli bir tarif kaynağı sağlayın."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Bu tarifin zaten bir zamanlaması var. Tarif listesinden düzenleyin."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Açıklama: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Başlık: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Kayıtlı tarifeler"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "Program kimliği gerekli."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Program:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Kayıtlı bir tarife seç"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Seçili: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "Đang xem ID lịch trình: {id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title} (đã lên lịch)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "Duyệt tìm tệp YAML..."
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "Sửa lịch trình"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "Không thể tải công thức."
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "Không thể phân tích công thức từ tệp."
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "Loại tệp không hợp lệ: Vui lòng chọn tệp YAML (.yaml hoặc .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "Đang tải công thức..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "Tên:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "ví dụ: daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "Không tìm thấy công thức đã lưu."
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "Vui lòng cung cấp nguồn công thức hợp lệ."
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "Công thức này đã có lịch. Chỉnh sửa từ danh sách Công thức."
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "Mô tả: {description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "Tiêu đề: {title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "Công thức đã lưu"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "Bắt buộc phải có ID lịch trình."
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "Lịch trình:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "Chọn một công thức đã lưu"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "Đã chọn: {path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "正在查看计划 ID:{id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title}(已安排)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "浏览 YAML 文件…"
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "编辑计划"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "加载方案失败。"
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "从文件解析配方失败。"
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "文件类型无效:请选择 YAML 文件(.yaml 或 .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "正在加载方案..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "名称:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "例如:daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "未找到已保存的方案。"
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "请提供有效的配方来源。"
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "此配方已有计划。请从配方列表中编辑。"
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "描述:{description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "标题:{title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "已保存的方案"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "计划 ID 为必填项。"
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "计划:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "选择一个已保存的方案"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "已选择:{path}"
|
||||
},
|
||||
|
||||
@@ -3425,6 +3425,9 @@
|
||||
"scheduleDetailView.viewingScheduleId": {
|
||||
"defaultMessage": "正在檢視排程 ID:{id}"
|
||||
},
|
||||
"scheduleModal.alreadyScheduledOption": {
|
||||
"defaultMessage": "{title}(已排程)"
|
||||
},
|
||||
"scheduleModal.browseYaml": {
|
||||
"defaultMessage": "瀏覽 YAML 檔案…"
|
||||
},
|
||||
@@ -3449,6 +3452,9 @@
|
||||
"scheduleModal.editSchedule": {
|
||||
"defaultMessage": "編輯排程"
|
||||
},
|
||||
"scheduleModal.failedLoadRecipes": {
|
||||
"defaultMessage": "載入方案失敗。"
|
||||
},
|
||||
"scheduleModal.failedParseRecipe": {
|
||||
"defaultMessage": "無法從檔案剖析食譜。"
|
||||
},
|
||||
@@ -3461,15 +3467,24 @@
|
||||
"scheduleModal.invalidFileType": {
|
||||
"defaultMessage": "無效的檔案類型:請選擇 YAML 檔案(.yaml 或 .yml)"
|
||||
},
|
||||
"scheduleModal.loadingRecipes": {
|
||||
"defaultMessage": "正在載入方案..."
|
||||
},
|
||||
"scheduleModal.nameLabel": {
|
||||
"defaultMessage": "名稱:"
|
||||
},
|
||||
"scheduleModal.namePlaceholder": {
|
||||
"defaultMessage": "例如:daily-summary-job"
|
||||
},
|
||||
"scheduleModal.noSavedRecipes": {
|
||||
"defaultMessage": "找不到已儲存的方案。"
|
||||
},
|
||||
"scheduleModal.provideValidRecipe": {
|
||||
"defaultMessage": "請提供有效的食譜來源。"
|
||||
},
|
||||
"scheduleModal.recipeAlreadyScheduled": {
|
||||
"defaultMessage": "此配方已有排程。請從配方清單中編輯。"
|
||||
},
|
||||
"scheduleModal.recipeDescription": {
|
||||
"defaultMessage": "描述:{description}"
|
||||
},
|
||||
@@ -3479,12 +3494,18 @@
|
||||
"scheduleModal.recipeTitle": {
|
||||
"defaultMessage": "標題:{title}"
|
||||
},
|
||||
"scheduleModal.savedRecipes": {
|
||||
"defaultMessage": "已儲存的方案"
|
||||
},
|
||||
"scheduleModal.scheduleIdRequired": {
|
||||
"defaultMessage": "排程 ID 為必填。"
|
||||
},
|
||||
"scheduleModal.scheduleLabel": {
|
||||
"defaultMessage": "排程:"
|
||||
},
|
||||
"scheduleModal.selectRecipePlaceholder": {
|
||||
"defaultMessage": "選擇一個已儲存的方案"
|
||||
},
|
||||
"scheduleModal.selected": {
|
||||
"defaultMessage": "已選取:{path}"
|
||||
},
|
||||
|
||||
@@ -30,12 +30,7 @@ export const saveRecipe = async (
|
||||
};
|
||||
|
||||
export const listSavedRecipes = async (): Promise<RecipeManifest[]> => {
|
||||
try {
|
||||
return await acpListRecipes();
|
||||
} catch (error) {
|
||||
console.warn('Failed to list saved recipes:', error);
|
||||
return [];
|
||||
}
|
||||
return await acpListRecipes();
|
||||
};
|
||||
|
||||
export const deleteRecipe = async (id: string): Promise<void> => {
|
||||
|
||||
@@ -70,7 +70,11 @@ export type AnalyticsEvent =
|
||||
}
|
||||
| {
|
||||
name: 'schedule_created';
|
||||
properties: { source_type: 'file' | 'deeplink'; success: boolean; error_details?: string };
|
||||
properties: {
|
||||
source_type: 'file' | 'deeplink' | 'saved';
|
||||
success: boolean;
|
||||
error_details?: string;
|
||||
};
|
||||
}
|
||||
| { name: 'schedule_deleted'; properties: { success: boolean; error_details?: string } }
|
||||
| { name: 'schedule_run_now'; properties: { success: boolean; error_details?: string } }
|
||||
@@ -419,7 +423,7 @@ export function trackExtensionDeleted(
|
||||
// ============================================================================
|
||||
|
||||
export function trackScheduleCreated(
|
||||
sourceType: 'file' | 'deeplink',
|
||||
sourceType: 'file' | 'deeplink' | 'saved',
|
||||
success: boolean,
|
||||
errorDetails?: string
|
||||
): void {
|
||||
|
||||
Reference in New Issue
Block a user