feat: AI description - Backend for adding translation objects to EventType model (#17657)
* feat: AI description - DB model + frontend + backend (fetch only) * fix types and add validation to backend * improve log * improve * import type * add EventTypeTranslationRepository * fix replexica error * fix replexica error * fix * fix test * wip * wip * add createManyDescriptionTranslations method * improve logic * refactor * update replexica type * improve typing * Renamed descriptionTranslations to fieldTranslations * log errors in replexica service * handle translations in background * don't create, upsert instead * make sure that description is updated * simplify * fix * Update packages/trpc/server/routers/viewer/eventTypes/update.handler.ts * improve filter logic in frontend * implement background job using Tasker * update schema * update schema again * rename * rename id to uid * add migration * fix type * update * update schema and migration sql * fix migration * fix type check * fix type check 2 * improve find logic in EventMeta * add more validation * add logger * rename * upgrade replexica/sdk to 0.7.0 * upgrade replexica to 0.7.0 * use batchLocalizeText * add comments * override browser locale if user is a signed in calcom user * fix type in replexica * fix migration sql * fix type * use dynamic import * do not use batchLocalizeText * fix * add more validations * fix type error * address comment * update locales * update locales --------- Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
co-authored by
Keith Williams
parent
93489709a8
commit
62976a4c77
@@ -64,7 +64,7 @@
|
||||
"@radix-ui/react-switch": "^1.0.0",
|
||||
"@radix-ui/react-toggle-group": "^1.0.0",
|
||||
"@radix-ui/react-tooltip": "^1.0.0",
|
||||
"@replexica/sdk": "^0.6.0",
|
||||
"@replexica/sdk": "^0.7.0",
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"@stripe/react-stripe-js": "^1.10.0",
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import { useSession } from "next-auth/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
@@ -11,6 +12,8 @@ import { EventMetaBlock } from "@calcom/features/bookings/components/event-meta/
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import type { BookerEvent } from "@calcom/features/bookings/types";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { locales as i18nLocales } from "@calcom/lib/i18n";
|
||||
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
|
||||
|
||||
import { fadeInUp } from "../config";
|
||||
import { useBookerStore } from "../store";
|
||||
@@ -59,6 +62,7 @@ export const EventMeta = ({
|
||||
eventMetaTimezoneSelect?: string;
|
||||
};
|
||||
}) => {
|
||||
const session = useSession();
|
||||
const { setTimezone, timeFormat, timezone } = useTimePreferences();
|
||||
const selectedDuration = useBookerStore((state) => state.selectedDuration);
|
||||
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
|
||||
@@ -103,9 +107,13 @@ export const EventMeta = ({
|
||||
: isHalfFull
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight";
|
||||
const browserLocale = navigator.language; // e.g. "en-US", "es-ES", "fr-FR"
|
||||
const translatedDescription = (event?.fieldTranslations ?? []).find((translation) =>
|
||||
browserLocale.startsWith(translation.targetLang)
|
||||
const userLocale = session.data?.user.locale ?? navigator.language;
|
||||
const translatedDescription = (event?.fieldTranslations ?? []).find(
|
||||
(trans) =>
|
||||
trans.field === EventTypeAutoTranslatedField.DESCRIPTION &&
|
||||
i18nLocales.includes(trans.targetLocale) &&
|
||||
// browser language looks like "en-US", "es-ES", "fr-FR", etc
|
||||
(userLocale === trans.targetLocale || userLocale.split("-")[0] === trans.targetLocale)
|
||||
)?.translatedText;
|
||||
|
||||
return (
|
||||
|
||||
@@ -71,7 +71,8 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
fieldTranslations: {
|
||||
select: {
|
||||
translatedText: true,
|
||||
targetLang: true,
|
||||
targetLocale: true,
|
||||
field: true,
|
||||
},
|
||||
},
|
||||
requiresBookerEmailVerification: true,
|
||||
|
||||
@@ -14,6 +14,9 @@ type TaskPayloads = {
|
||||
triggerFormSubmittedNoEventWebhook: z.infer<
|
||||
typeof import("./tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook").ZTriggerFormSubmittedNoEventWebhookPayloadSchema
|
||||
>;
|
||||
translateEventTypeDescription: z.infer<
|
||||
typeof import("./tasks/translateEventTypeDescription").ZTranslateEventTypeDescriptionPayloadSchema
|
||||
>;
|
||||
};
|
||||
export type TaskTypes = keyof TaskPayloads;
|
||||
export type TaskHandler = (payload: string) => Promise<void>;
|
||||
|
||||
@@ -17,6 +17,8 @@ const tasks: Record<TaskTypes, () => Promise<TaskHandler>> = {
|
||||
(module) => module.triggerFormSubmittedNoEventWebhook
|
||||
),
|
||||
sendSms: () => Promise.resolve(() => Promise.reject(new Error("Not implemented"))),
|
||||
translateEventTypeDescription: () =>
|
||||
import("./translateEventTypeDescription").then((module) => module.translateEventTypeDescription),
|
||||
};
|
||||
|
||||
export default tasks;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { locales as i18nLocales } from "@calcom/lib/i18n";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { EventTypeTranslationRepository } from "@calcom/lib/server/repository/eventTypeTranslation";
|
||||
|
||||
export const ZTranslateEventTypeDescriptionPayloadSchema = z.object({
|
||||
eventTypeId: z.number(),
|
||||
userId: z.number(),
|
||||
description: z.string(),
|
||||
userLocale: z.string(),
|
||||
});
|
||||
|
||||
const SUPPORTED_LOCALES = [
|
||||
"en", // English
|
||||
"es", // Spanish
|
||||
"de", // German
|
||||
"pt", // Portuguese
|
||||
"pt-BR", // Portuguese Brazilian
|
||||
"fr", // French
|
||||
"it", // Italian
|
||||
"ar", // Arabic
|
||||
"ru", // Russian
|
||||
"zh-CN", // Simplified Chinese
|
||||
"nl", // Dutch
|
||||
"zh-TW", // Traditional Chinese
|
||||
"ko", // Korean
|
||||
"ja", // Japanese
|
||||
"sv", // Swedish
|
||||
"da", // Danish
|
||||
] as const;
|
||||
|
||||
export async function translateEventTypeDescription(payload: string): Promise<void> {
|
||||
const { eventTypeId, description, userLocale, userId } = ZTranslateEventTypeDescriptionPayloadSchema.parse(
|
||||
JSON.parse(payload)
|
||||
);
|
||||
|
||||
const targetLocales = SUPPORTED_LOCALES.filter(
|
||||
(locale) => locale !== userLocale && i18nLocales.includes(locale)
|
||||
);
|
||||
|
||||
const { ReplexicaService } = await import("@calcom/lib/server/service/replexica");
|
||||
try {
|
||||
const translatedDescriptions = await Promise.all(
|
||||
targetLocales.map((targetLocale) =>
|
||||
ReplexicaService.localizeText(description, userLocale, targetLocale)
|
||||
)
|
||||
);
|
||||
|
||||
const validTranslations = translatedDescriptions
|
||||
.map((translatedText, index) => ({
|
||||
translatedText,
|
||||
targetLocale: targetLocales[index],
|
||||
}))
|
||||
.filter(
|
||||
(item): item is { translatedText: string; targetLocale: (typeof SUPPORTED_LOCALES)[number] } =>
|
||||
item.translatedText !== null
|
||||
);
|
||||
|
||||
if (validTranslations.length > 0) {
|
||||
await EventTypeTranslationRepository.upsertManyDescriptionTranslations(
|
||||
validTranslations.map(({ translatedText, targetLocale }) => ({
|
||||
eventTypeId,
|
||||
sourceLocale: userLocale,
|
||||
targetLocale,
|
||||
// Keep old fields during transition
|
||||
sourceLang: userLocale, // TODO: remove after migration
|
||||
targetLang: targetLocale, // TODO: remove after migration
|
||||
translatedText,
|
||||
userId,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to process event type description translations:", error);
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,8 @@ export class EventTypeRepository {
|
||||
fieldTranslations: {
|
||||
select: {
|
||||
translatedText: true,
|
||||
targetLang: true,
|
||||
targetLocale: true,
|
||||
field: true,
|
||||
},
|
||||
},
|
||||
recurringEvent: true,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { EventTypeTranslation } from "@calcom/prisma/client";
|
||||
import { EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
|
||||
|
||||
export type CreateEventTypeDescriptionTranslation = Omit<
|
||||
EventTypeTranslation,
|
||||
| "id" // TODO: remove after migration
|
||||
| "uid"
|
||||
| "createdAt"
|
||||
| "createdBy"
|
||||
| "updatedAt"
|
||||
| "updatedBy"
|
||||
| "eventType"
|
||||
| "field"
|
||||
| "creator"
|
||||
| "updater"
|
||||
> & { userId: number };
|
||||
|
||||
export class EventTypeTranslationRepository {
|
||||
static async upsertManyDescriptionTranslations(translations: Array<CreateEventTypeDescriptionTranslation>) {
|
||||
return await Promise.all(
|
||||
translations.map(({ userId, ...translation }) => {
|
||||
const whereClause = translation.targetLang // TODO: remove after migration
|
||||
? {
|
||||
eventTypeId_field_targetLang: {
|
||||
eventTypeId: translation.eventTypeId,
|
||||
field: EventTypeAutoTranslatedField.DESCRIPTION,
|
||||
targetLang: translation.targetLang,
|
||||
},
|
||||
}
|
||||
: {
|
||||
eventTypeId_field_targetLocale: {
|
||||
eventTypeId: translation.eventTypeId,
|
||||
field: EventTypeAutoTranslatedField.DESCRIPTION,
|
||||
targetLocale: translation.targetLocale,
|
||||
},
|
||||
};
|
||||
return prisma.eventTypeTranslation.upsert({
|
||||
where: whereClause,
|
||||
update: {
|
||||
translatedText: translation.translatedText,
|
||||
updatedBy: userId,
|
||||
},
|
||||
create: {
|
||||
...translation,
|
||||
field: EventTypeAutoTranslatedField.DESCRIPTION,
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ReplexicaEngine } from "@replexica/sdk";
|
||||
import type { LocaleCode } from "@replexica/spec";
|
||||
|
||||
import { REPLEXICA_API_KEY } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
export class ReplexicaService {
|
||||
private static engine = new ReplexicaEngine({
|
||||
@@ -14,9 +16,13 @@ export class ReplexicaService {
|
||||
* @param targetLocale The target language locale
|
||||
* @returns The localized text
|
||||
*/
|
||||
static async localizeText(text: string, sourceLocale: string, targetLocale: string): Promise<string> {
|
||||
static async localizeText(
|
||||
text: string,
|
||||
sourceLocale: string,
|
||||
targetLocale: string
|
||||
): Promise<string | null> {
|
||||
if (!text?.trim()) {
|
||||
return text;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -27,7 +33,33 @@ export class ReplexicaService {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
return text;
|
||||
logger.error(`ReplexicaService.localizeText() failed for targetLocale: ${targetLocale} - ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize a text string to multiple target locales
|
||||
* @param text The text to localize
|
||||
* @param sourceLocale The source language locale
|
||||
* @param targetLocales Array of the target language locales
|
||||
* @returns The localized texts
|
||||
*/
|
||||
static async batchLocalizeText(
|
||||
text: string,
|
||||
sourceLocale: string,
|
||||
targetLocales: string[]
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const result = await this.engine.batchLocalizeText(text, {
|
||||
sourceLocale: sourceLocale as LocaleCode,
|
||||
targetLocales: targetLocales as LocaleCode[],
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`ReplexicaService.batchLocalizeText() failed: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +86,7 @@ export class ReplexicaService {
|
||||
|
||||
return result.map((chat: { name: string; text: string }) => chat.text);
|
||||
} catch (error) {
|
||||
logger.error(`ReplexicaService.localizeTexts() failed: ${error}`);
|
||||
return texts;
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `sourceLocale` to the `EventTypeTranslation` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `targetLocale` to the `EventTypeTranslation` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventTypeTranslation" ADD COLUMN "sourceLocale" TEXT,
|
||||
ADD COLUMN "targetLocale" TEXT;
|
||||
|
||||
-- Update data
|
||||
UPDATE "EventTypeTranslation" SET
|
||||
"sourceLocale" = "sourceLang",
|
||||
"targetLocale" = "targetLang";
|
||||
|
||||
-- Then make NOT NULL after data is copied
|
||||
ALTER TABLE "EventTypeTranslation"
|
||||
ALTER COLUMN "sourceLocale" SET NOT NULL,
|
||||
ALTER COLUMN "targetLocale" SET NOT NULL;
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[eventTypeId,field,targetLocale]` on the table `EventTypeTranslation` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "EventTypeTranslation" ADD COLUMN "uid" TEXT;
|
||||
|
||||
-- Copy data
|
||||
UPDATE "EventTypeTranslation" SET
|
||||
"uid" = "id";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventTypeTranslation_eventTypeId_field_targetLocale_idx" ON "EventTypeTranslation"("eventTypeId", "field", "targetLocale");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "EventTypeTranslation_eventTypeId_field_targetLocale_key" ON "EventTypeTranslation"("eventTypeId", "field", "targetLocale");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "EventTypeTranslation" ADD CONSTRAINT "EventTypeTranslation_updatedBy_fkey" FOREIGN KEY ("updatedBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -355,6 +355,8 @@ model User {
|
||||
referralLinkId String?
|
||||
features UserFeatures[]
|
||||
reassignedBookings Booking[] @relation("reassignByUser")
|
||||
createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations")
|
||||
updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations")
|
||||
|
||||
@@unique([email])
|
||||
@@unique([email, username])
|
||||
@@ -1632,17 +1634,24 @@ enum EventTypeAutoTranslatedField {
|
||||
|
||||
model EventTypeTranslation {
|
||||
id String @id @default(cuid())
|
||||
uid String?
|
||||
eventType EventType @relation(fields: [eventTypeId], references: [id], onDelete: Cascade)
|
||||
eventTypeId Int
|
||||
field EventTypeAutoTranslatedField
|
||||
sourceLang String
|
||||
targetLang String
|
||||
sourceLocale String
|
||||
targetLocale String
|
||||
translatedText String @db.Text
|
||||
createdAt DateTime @default(now())
|
||||
createdBy Int
|
||||
updatedAt DateTime @updatedAt
|
||||
updatedBy Int?
|
||||
creator User @relation("CreatedEventTypeTranslations", fields: [createdBy], references: [id])
|
||||
updater User? @relation("UpdatedEventTypeTranslations", fields: [updatedBy], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([eventTypeId, field, targetLang])
|
||||
@@index([eventTypeId, field, targetLang])
|
||||
@@unique([eventTypeId, field, targetLang]) // TODO: remove after migration
|
||||
@@unique([eventTypeId, field, targetLocale])
|
||||
@@index([eventTypeId, field, targetLang]) // TODO: remove after migration
|
||||
@@index([eventTypeId, field, targetLocale])
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import tasker from "@calcom/features/tasker";
|
||||
import { validateIntervalLimitOrder } from "@calcom/lib";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { WorkflowTriggerEvents } from "@calcom/prisma/client";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { SchedulingType, EventTypeAutoTranslatedField } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -36,6 +37,7 @@ type User = {
|
||||
};
|
||||
selectedCalendars: SessionUser["selectedCalendars"];
|
||||
organizationId: number | null;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
type UpdateOptions = {
|
||||
@@ -76,6 +78,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
aiPhoneCallConfig,
|
||||
isRRWeightsEnabled,
|
||||
autoTranslateDescriptionEnabled,
|
||||
description: newDescription,
|
||||
...rest
|
||||
} = input;
|
||||
|
||||
@@ -83,6 +86,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
where: { id },
|
||||
select: {
|
||||
title: true,
|
||||
description: true,
|
||||
fieldTranslations: {
|
||||
select: {
|
||||
field: true,
|
||||
},
|
||||
},
|
||||
isRRWeightsEnabled: true,
|
||||
hosts: {
|
||||
select: {
|
||||
@@ -153,10 +162,17 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
ensureUniqueBookingFields(bookingFields);
|
||||
ensureEmailOrPhoneNumberIsPresent(bookingFields);
|
||||
|
||||
if (autoTranslateDescriptionEnabled && !ctx.user.organizationId) {
|
||||
logger.error(
|
||||
"Auto-translating description requires an organization. This should not happen - UI controls should prevent this state."
|
||||
);
|
||||
}
|
||||
|
||||
const data: Prisma.EventTypeUpdateInput = {
|
||||
...rest,
|
||||
// autoTranslate feature is allowed for org users only
|
||||
autoTranslateDescriptionEnabled: !!(ctx.user.organizationId && autoTranslateDescriptionEnabled),
|
||||
description: newDescription,
|
||||
bookingFields,
|
||||
isRRWeightsEnabled,
|
||||
rrSegmentQueryValue:
|
||||
@@ -480,6 +496,27 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Logic for updating `fieldTranslations`
|
||||
// user has no description translations OR user is changing the description
|
||||
const descriptionTranslationsNeeded =
|
||||
eventType.fieldTranslations.filter((trans) => trans.field === EventTypeAutoTranslatedField.DESCRIPTION)
|
||||
.length === 0 || newDescription;
|
||||
const description = newDescription ?? eventType.description;
|
||||
|
||||
if (
|
||||
ctx.user.organizationId &&
|
||||
autoTranslateDescriptionEnabled &&
|
||||
descriptionTranslationsNeeded &&
|
||||
description
|
||||
) {
|
||||
await tasker.create("translateEventTypeDescription", {
|
||||
eventTypeId: id,
|
||||
description,
|
||||
userLocale: ctx.user.locale,
|
||||
userId: ctx.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedEventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
|
||||
slug: true,
|
||||
schedulingType: true,
|
||||
|
||||
@@ -5035,7 +5035,7 @@ __metadata:
|
||||
"@radix-ui/react-switch": ^1.0.0
|
||||
"@radix-ui/react-toggle-group": ^1.0.0
|
||||
"@radix-ui/react-tooltip": ^1.0.0
|
||||
"@replexica/sdk": ^0.6.0
|
||||
"@replexica/sdk": ^0.7.0
|
||||
"@sentry/nextjs": ^8.8.0
|
||||
"@stripe/react-stripe-js": ^1.10.0
|
||||
"@stripe/stripe-js": ^1.35.0
|
||||
@@ -12316,28 +12316,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@replexica/sdk@npm:^0.6.0":
|
||||
version: 0.6.0
|
||||
resolution: "@replexica/sdk@npm:0.6.0"
|
||||
"@replexica/sdk@npm:^0.7.0":
|
||||
version: 0.7.2
|
||||
resolution: "@replexica/sdk@npm:0.7.2"
|
||||
dependencies:
|
||||
"@paralleldrive/cuid2": ^2.2.2
|
||||
"@replexica/spec": 0.18.0
|
||||
"@replexica/spec": 0.19.0
|
||||
jsdom: ^25.0.1
|
||||
typescript: ^5.6.3
|
||||
vitest: ^2.1.4
|
||||
zod: ^3.23.8
|
||||
checksum: 95a239cf24025a8ea1a03b0720c8a1bd351f73777015fd3a8b74995740fb191c87d157ac04e7b1824f9b5b8f27cef3ce1b7126deb7bf7683b74b8435d3de1238
|
||||
checksum: 5199f4c7721eaefa66f9da062b11d62d79249afaa30098325d3e52550e2ac5fb2c3bdf550e61691e020023f11718a5240d38c8ac54726159e075991150620535
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@replexica/spec@npm:0.18.0":
|
||||
version: 0.18.0
|
||||
resolution: "@replexica/spec@npm:0.18.0"
|
||||
"@replexica/spec@npm:0.19.0":
|
||||
version: 0.19.0
|
||||
resolution: "@replexica/spec@npm:0.19.0"
|
||||
dependencies:
|
||||
typescript: ^5.6.3
|
||||
vitest: ^2.1.4
|
||||
zod: ^3.23.8
|
||||
checksum: d9afcfd965b804d395436cc6daf9a051142e13658297e45f742a6397699360e8f28d43399c4c31077b250ca965e1733200bf8c63eae328b4960320d2dd6ae5b5
|
||||
checksum: 11114313e42c43ffe5c68e709eb465f4bbd0e24c70fcbdec5e56b4c2a9c4c10b5cd5899c253ffb652b1d66db13015f9ccbcf1d11479e079361c9d6bf8170ebf5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user