From e1d388c6b84d2dfa1b77d9972cbcba7e8e37bba8 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Sun, 16 Aug 2026 12:58:55 +0200 Subject: [PATCH 1/6] fixed user account deletion, improved project ownership transfer --- .../account/ProfileSettings.module.css | 21 +++ .../dashboard/account/ProfileSettings.tsx | 54 +++++-- .../dashboard/project/DangerZone.module.css | 16 ++ components/dashboard/project/DangerZone.tsx | 136 +++++++++++++++-- messages/de.json | 12 +- messages/en.json | 12 +- messages/es.json | 12 +- messages/fr.json | 12 +- messages/ja.json | 12 +- messages/ko.json | 12 +- messages/pl.json | 12 +- messages/zh.json | 12 +- .../migration.sql | 5 + prisma/schema.prisma | 2 +- src/app/api/projects/[projectId]/route.ts | 17 +-- .../[projectId]/transfer-ownership/route.ts | 81 +++++++++++ src/app/api/users/route.ts | 9 +- src/lib/cloud/index.ts | 5 +- src/lib/cloud/room.ts | 137 +++++++++++++++--- src/lib/cloud/utils.ts | 60 ++++---- src/lib/s3.ts | 51 +++++++ src/lib/utils/api-bodies.ts | 5 + src/lib/utils/requests.ts | 5 + .../repository/magic-link-repository.ts | 4 + src/server/repository/project-repository.ts | 30 ++++ src/server/repository/user-repository.ts | 5 + .../service/account-deletion-service.ts | 86 +++++++++++ src/server/service/magic-link-service.ts | 2 + src/server/service/project-service.ts | 17 +++ .../service/project-teardown-service.ts | 31 ++++ src/server/service/user-service.ts | 4 + 31 files changed, 778 insertions(+), 101 deletions(-) create mode 100644 prisma/migrations/20260816123000_project_member_user_cascade/migration.sql create mode 100644 src/app/api/projects/[projectId]/transfer-ownership/route.ts create mode 100644 src/server/service/account-deletion-service.ts create mode 100644 src/server/service/project-teardown-service.ts diff --git a/components/dashboard/account/ProfileSettings.module.css b/components/dashboard/account/ProfileSettings.module.css index 77811fdb..4241265c 100644 --- a/components/dashboard/account/ProfileSettings.module.css +++ b/components/dashboard/account/ProfileSettings.module.css @@ -119,6 +119,27 @@ border: 1px solid rgba(239, 68, 68, 0.2); } +/* Shown in the delete-account dialog when a live subscription is at stake */ +.subscriptionWarning { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 16px; + margin-bottom: 20px; + border-radius: 8px; + background: rgba(234, 179, 8, 0.1); + border: 1px solid rgba(234, 179, 8, 0.25); + font-size: 0.85rem; + line-height: 1.5; + color: var(--primary-text); +} + +.subscriptionWarningIcon { + flex-shrink: 0; + margin-top: 2px; + color: #eab308; +} + .proStatus { display: flex; flex-direction: column; diff --git a/components/dashboard/account/ProfileSettings.tsx b/components/dashboard/account/ProfileSettings.tsx index f5e6324f..475b2710 100644 --- a/components/dashboard/account/ProfileSettings.tsx +++ b/components/dashboard/account/ProfileSettings.tsx @@ -5,7 +5,7 @@ import { editUserInfo, deleteUser } from "@src/lib/utils/requests"; import { signOut } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useRouter } from "next/navigation"; -import { ArrowRight, Trash2, Save } from "lucide-react"; +import { ArrowRight, Trash2, Save, TriangleAlert } from "lucide-react"; import { useTranslations } from "next-intl"; import form from "./../../utils/Form.module.css"; @@ -15,6 +15,7 @@ import dangerStyles from "../project/DangerZone.module.css"; import modal from "../../utils/ModalBtn.module.css"; import { ApiResponse } from "@src/lib/utils/api-utils"; import { useUser } from "@src/lib/utils/hooks"; +import { useLocale } from "@src/context/LocaleContext"; const PRESET_COLORS = [ "#ef4444", // red @@ -32,8 +33,22 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const router = useRouter(); const t = useTranslations("profile"); const tCommon = useTranslations("common"); + const { locale } = useLocale(); const confirmPhrase = t("deleteConfirmPhrase"); + // A subscription still renewing is money at stake: deleting the account + // cancels it on the spot, so the dialog has to say so before they confirm. + // Apple bills through the App Store and we cannot cancel it server-side — + // that case needs the opposite warning, or the user assumes billing stops. + const isPro = !!user?.isProUntil && new Date(user.isProUntil) > new Date(); + const hasLiveSubscription = isPro && !user?.isSubscriptionCancelled; + const isAppleSubscription = user?.subscriptionProvider === "APPLE"; + const proExpiryDate = user?.isProUntil + ? new Intl.DateTimeFormat(locale, { year: "numeric", month: "long", day: "numeric" }).format( + new Date(user.isProUntil), + ) + : ""; + const [username, setUsername] = useState(""); const [color, setColor] = useState(PRESET_COLORS[0]); const [isDirty, setDirty] = useState(false); @@ -43,6 +58,7 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); const [deleteLoading, setDeleteLoading] = useState(false); + const [deleteError, setDeleteError] = useState(null); // Sync state when settings load useEffect(() => { @@ -67,17 +83,23 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const handleDeleteAccount = async () => { setDeleteLoading(true); + setDeleteError(null); try { const res = await deleteUser(); - if (res.ok) { - if (isTauri()) { - const { clearDesktopToken } = await import("@src/lib/desktop-auth"); - await clearDesktopToken(); - } else { - await signOut({ redirect: false }); - } - router.replace("/"); + if (!res.ok) { + setDeleteError(t("deleteFailed")); + return; + } + + if (isTauri()) { + const { clearDesktopToken } = await import("@src/lib/desktop-auth"); + await clearDesktopToken(); + } else { + await signOut({ redirect: false }); } + router.replace("/"); + } catch { + setDeleteError(t("deleteFailed")); } finally { setDeleteLoading(false); } @@ -130,6 +152,16 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean;

{t("deleteModalTitle")}

{t("deleteModalDesc")}

+ {hasLiveSubscription && ( +
+ + + {isAppleSubscription + ? t("deleteSubscriptionWarningApple", { date: proExpiryDate }) + : t("deleteSubscriptionWarning", { date: proExpiryDate })} + +
+ )}
+ {/* Ownership transfer dialog */} + {showTransferDialog && ( +
+
+

{t("transferModalTitle")}

+

{t("transferModalDesc")}

+ + {candidates.length === 0 ? ( +

{t("transferNoCandidates")}

+ ) : ( + <> + + + + )} + + {transferError &&

{transferError}

} + +
+ {candidates.length > 0 && ( + + )} + +
+
+
+ )} + {/* Delete confirmation dialog */} {showDeleteDialog && (
diff --git a/messages/de.json b/messages/de.json index a26bf649..a4fb8888 100644 --- a/messages/de.json +++ b/messages/de.json @@ -209,7 +209,14 @@ "transferOwnership": "Besitz übertragen", "transferDesc": "Übertragen Sie Ihren Besitzerstatus auf einen anderen Benutzer. Sie werden zum Editor.", "transferBtn": "Übertragen", - "transferPrompt": "Geben Sie die E-Mail-Adresse des neuen Besitzers ein:", + "transferModalTitle": "Besitz übertragen", + "transferModalDesc": "Der neue Besitzer übernimmt dieses Projekt samt Cloud-Speicher. Sie behalten den Zugriff als Editor, und nur der neue Besitzer kann den Besitz zurückgeben.", + "transferSelectLabel": "Neuer Besitzer", + "transferNoCandidates": "Es gibt niemanden, an den dieses Projekt übertragen werden könnte. Laden Sie zuerst einen Mitarbeiter ein.", + "transferring": "Übertragen...", + "confirmTransferBtn": "Besitz übertragen", + "transferProRequired": "Der neue Besitzer benötigt ein aktives Pro-Abonnement.", + "transferFailed": "Übertragung des Besitzes fehlgeschlagen. Bitte versuchen Sie es erneut.", "deleteProject": "Projekt löschen", "deleteProjectDesc": "Sobald ein Projekt gelöscht wurde, gibt es kein Zurück mehr. Bitte seien Sie sicher.", "deleteBtn": "Löschen", @@ -236,10 +243,13 @@ "deleteBtn": "Löschen", "deleteModalTitle": "Konto löschen", "deleteModalDesc": "Dies wird dauerhaft Ihr Konto und alle verknüpften Daten löschen. Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteSubscriptionWarning": "Dein Pro-Abo läuft bis zum {date}. Wenn du dein Konto löschst, wird es sofort gekündigt – die verbleibende Zeit verfällt und wird nicht erstattet.", + "deleteSubscriptionWarningApple": "Dein Pro-Abo wird über den App Store abgerechnet und läuft bis zum {date}. Das Löschen deines Kontos beendet deinen Pro-Zugang, stoppt aber nicht die Abrechnung – kündige das Abo zusätzlich in den App-Store-Einstellungen.", "deleteConfirmPhrase": "Ich bestätige die Löschung meines Kontos", "deleteConfirmLabel": "Gib folgenden Text ein, um die Löschung zu bestätigen:", "deleting": "Löschen...", "deleteAccountBtn": "Mein Konto löschen", + "deleteFailed": "Konto konnte nicht gelöscht werden. Bitte erneut versuchen.", "subscription": { "openWebsite": "Auf scriptio.app verwalten", "title": "Abonnement", diff --git a/messages/en.json b/messages/en.json index 8c8a878b..1fc7cc79 100644 --- a/messages/en.json +++ b/messages/en.json @@ -208,7 +208,14 @@ "transferOwnership": "Transfer ownership", "transferDesc": "Transfer your owner role to another user. You will be given editor role.", "transferBtn": "Transfer", - "transferPrompt": "Enter the email of the new owner:", + "transferModalTitle": "Transfer ownership", + "transferModalDesc": "The new owner takes over this project and its cloud storage. You will keep access as an editor, and only the new owner will be able to transfer it back.", + "transferSelectLabel": "New owner", + "transferNoCandidates": "There is no one to transfer this project to. Invite a collaborator first.", + "transferring": "Transferring...", + "confirmTransferBtn": "Transfer ownership", + "transferProRequired": "The new owner needs an active Pro subscription.", + "transferFailed": "Failed to transfer ownership. Please try again.", "deleteProject": "Delete project", "deleteProjectDesc": "Once you delete a project, there is no going back. Please be certain.", "deleteBtn": "Delete", @@ -235,10 +242,13 @@ "deleteBtn": "Delete", "deleteModalTitle": "Delete account", "deleteModalDesc": "This will permanently delete your account and all associated data. This action cannot be undone.", + "deleteSubscriptionWarning": "Your Pro subscription is active until {date}. Deleting your account cancels it immediately — the remaining time is lost and will not be refunded.", + "deleteSubscriptionWarningApple": "Your Pro subscription is billed through the App Store and stays active until {date}. Deleting your account ends your Pro access, but does not stop the billing — cancel the subscription in your App Store settings too.", "deleteConfirmPhrase": "I confirm my account deletion", "deleteConfirmLabel": "Type the following text to confirm deletion:", "deleting": "Deleting...", "deleteAccountBtn": "Delete my account", + "deleteFailed": "Failed to delete your account. Please try again.", "subscription": { "openWebsite": "Manage on scriptio.app", "title": "Subscription", diff --git a/messages/es.json b/messages/es.json index 34b9a046..6e461006 100644 --- a/messages/es.json +++ b/messages/es.json @@ -208,7 +208,14 @@ "transferOwnership": "Transferir propiedad", "transferDesc": "Transfiere tu rol de propietario a otro usuario. Pasarás a tener el rol de editor.", "transferBtn": "Transferir", - "transferPrompt": "Introduce el correo del nuevo propietario:", + "transferModalTitle": "Transferir propiedad", + "transferModalDesc": "El nuevo propietario asume este proyecto y su almacenamiento en la nube. Conservarás el acceso como editor, y solo el nuevo propietario podrá devolvértelo.", + "transferSelectLabel": "Nuevo propietario", + "transferNoCandidates": "No hay nadie a quien transferir este proyecto. Invita primero a un colaborador.", + "transferring": "Transfiriendo...", + "confirmTransferBtn": "Transferir propiedad", + "transferProRequired": "El nuevo propietario necesita una suscripción Pro activa.", + "transferFailed": "No se pudo transferir la propiedad. Inténtalo de nuevo.", "deleteProject": "Eliminar proyecto", "deleteProjectDesc": "Once un proyecto es eliminado, no hay vuelta atrás. Por favor, asegúrate.", "deleteBtn": "Eliminar", @@ -235,10 +242,13 @@ "deleteBtn": "Eliminar", "deleteModalTitle": "Eliminar cuenta", "deleteModalDesc": "Esto eliminará permanentemente tu cuenta y todos los datos asociados. Esta acción no se puede deshacer.", + "deleteSubscriptionWarning": "Tu suscripción Pro está activa hasta el {date}. Eliminar tu cuenta la cancela de inmediato: el tiempo restante se pierde y no se reembolsa.", + "deleteSubscriptionWarningApple": "Tu suscripción Pro se factura a través de la App Store y sigue activa hasta el {date}. Eliminar tu cuenta finaliza tu acceso Pro, pero no detiene el cobro: cancela también la suscripción en los ajustes de la App Store.", "deleteConfirmPhrase": "Confirmo la eliminación de mi cuenta", "deleteConfirmLabel": "Escribe el siguiente texto para confirmar la eliminación:", "deleting": "Eliminando...", "deleteAccountBtn": "Eliminar mi cuenta", + "deleteFailed": "No se pudo eliminar tu cuenta. Inténtalo de nuevo.", "subscription": { "openWebsite": "Gestionar en scriptio.app", "title": "Suscripción", diff --git a/messages/fr.json b/messages/fr.json index 7cd9a3d1..8a7cd14a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -209,7 +209,14 @@ "transferOwnership": "Transférer la propriété", "transferDesc": "Transférez votre rôle de propriétaire à un autre utilisateur. Vous obtiendrez le rôle d'éditeur.", "transferBtn": "Transférer", - "transferPrompt": "Saisissez l'adresse e-mail du nouveau propriétaire :", + "transferModalTitle": "Transférer la propriété", + "transferModalDesc": "Le nouveau propriétaire reprend ce projet et son stockage cloud. Vous conserverez l'accès en tant qu'éditeur, et seul le nouveau propriétaire pourra vous le rendre.", + "transferSelectLabel": "Nouveau propriétaire", + "transferNoCandidates": "Aucun membre à qui transférer ce projet. Invitez d'abord un collaborateur.", + "transferring": "Transfert...", + "confirmTransferBtn": "Transférer la propriété", + "transferProRequired": "Le nouveau propriétaire doit avoir un abonnement Pro actif.", + "transferFailed": "Échec du transfert de la propriété. Veuillez réessayer.", "deleteProject": "Supprimer le projet", "deleteProjectDesc": "La suppression d'un projet est irréversible. Veuillez être certain.", "deleteBtn": "Supprimer", @@ -236,10 +243,13 @@ "deleteBtn": "Supprimer", "deleteModalTitle": "Supprimer le compte", "deleteModalDesc": "Cela supprimera définitivement votre compte et toutes les données associées. Cette action ne peut pas être annulée.", + "deleteSubscriptionWarning": "Votre abonnement Pro est actif jusqu'au {date}. La suppression de votre compte l'annule immédiatement : le temps restant est perdu et ne sera pas remboursé.", + "deleteSubscriptionWarningApple": "Votre abonnement Pro est facturé via l'App Store et reste actif jusqu'au {date}. La suppression de votre compte met fin à votre accès Pro, mais n'arrête pas la facturation : annulez aussi l'abonnement dans les réglages de l'App Store.", "deleteConfirmPhrase": "Je confirme la suppression de mon compte", "deleteConfirmLabel": "Écrivez le texte suivant pour confirmer la suppression :", "deleting": "Suppression...", "deleteAccountBtn": "Supprimer mon compte", + "deleteFailed": "Échec de la suppression de votre compte. Veuillez réessayer.", "subscription": { "openWebsite": "Gérer sur scriptio.app", "title": "Abonnement", diff --git a/messages/ja.json b/messages/ja.json index 4be9514b..58a7cb40 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -208,7 +208,14 @@ "transferOwnership": "所有権を譲渡", "transferDesc": "所有者権限を他のユーザーに譲渡します。あなたは編集者権限に変更されます。", "transferBtn": "譲渡", - "transferPrompt": "新しい所有者のメールアドレスを入力してください:", + "transferModalTitle": "所有権を譲渡", + "transferModalDesc": "新しい所有者がこのプロジェクトとクラウドストレージを引き継ぎます。あなたは編集者としてアクセスを継続できますが、所有権を戻せるのは新しい所有者だけです。", + "transferSelectLabel": "新しい所有者", + "transferNoCandidates": "このプロジェクトを譲渡できる相手がいません。まず共同編集者を招待してください。", + "transferring": "譲渡中...", + "confirmTransferBtn": "所有権を譲渡", + "transferProRequired": "新しい所有者には有効なProサブスクリプションが必要です。", + "transferFailed": "所有権の譲渡に失敗しました。もう一度お試しください。", "deleteProject": "プロジェクトを削除", "deleteProjectDesc": "一度プロジェクトを削除すると、元に戻すことはできません。慎重に決定してください。", "deleteBtn": "削除", @@ -235,10 +242,13 @@ "deleteBtn": "削除", "deleteModalTitle": "アカウントを削除", "deleteModalDesc": "アカウントとすべての関連データを完全に削除します。この操作は取り消せません。", + "deleteSubscriptionWarning": "Proサブスクリプションは{date}まで有効です。アカウントを削除すると直ちに解約され、残りの期間は失われ、返金されません。", + "deleteSubscriptionWarningApple": "ProサブスクリプションはApp Store経由で課金され、{date}まで有効です。アカウントを削除するとProへのアクセスは終了しますが、課金は停止しません。App Storeの設定でもサブスクリプションを解約してください。", "deleteConfirmPhrase": "アカウントの削除を承認します", "deleteConfirmLabel": "削除を確認するために以下のテキストを入力してください:", "deleting": "削除中...", "deleteAccountBtn": "自分のアカウントを削除", + "deleteFailed": "アカウントを削除できませんでした。もう一度お試しください。", "subscription": { "openWebsite": "scriptio.app で管理", "title": "サブスクリプション", diff --git a/messages/ko.json b/messages/ko.json index 1daa1f10..054c3a65 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -208,7 +208,14 @@ "transferOwnership": "소유권 이전", "transferDesc": "소유자 권한을 다른 사용자에게 이전합니다. 당신은 편집자 권한으로 변경됩니다.", "transferBtn": "이전", - "transferPrompt": "새 소유자의 이메일을 입력하세요:", + "transferModalTitle": "소유권 이전", + "transferModalDesc": "새 소유자가 이 프로젝트와 클라우드 저장 공간을 넘겨받습니다. 당신은 편집자로 계속 접근할 수 있으며, 소유권을 되돌릴 수 있는 사람은 새 소유자뿐입니다.", + "transferSelectLabel": "새 소유자", + "transferNoCandidates": "이 프로젝트를 이전할 대상이 없습니다. 먼저 공동 작업자를 초대하세요.", + "transferring": "이전 중...", + "confirmTransferBtn": "소유권 이전", + "transferProRequired": "새 소유자에게 활성 Pro 구독이 필요합니다.", + "transferFailed": "소유권 이전에 실패했습니다. 다시 시도해 주세요.", "deleteProject": "프로젝트 삭제", "deleteProjectDesc": "프로젝트를 삭제하면 복구할 수 없습니다. 신중히 결정하세요.", "deleteBtn": "삭제", @@ -235,10 +242,13 @@ "deleteBtn": "삭제", "deleteModalTitle": "계정 삭제", "deleteModalDesc": "계정과 모든 데이터를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", + "deleteSubscriptionWarning": "Pro 구독이 {date}까지 유효합니다. 계정을 삭제하면 구독이 즉시 해지되며, 남은 기간은 사라지고 환불되지 않습니다.", + "deleteSubscriptionWarningApple": "Pro 구독은 App Store를 통해 청구되며 {date}까지 유효합니다. 계정을 삭제하면 Pro 이용은 종료되지만 청구는 중단되지 않습니다. App Store 설정에서도 구독을 해지하세요.", "deleteConfirmPhrase": "계정 삭제를 확인합니다", "deleteConfirmLabel": "삭제를 확인하려면 아래 텍스트를 입력하세요:", "deleting": "삭제 중...", "deleteAccountBtn": "내 계정 삭제", + "deleteFailed": "계정을 삭제하지 못했습니다. 다시 시도해 주세요.", "subscription": { "openWebsite": "scriptio.app에서 관리", "title": "구독", diff --git a/messages/pl.json b/messages/pl.json index 853547a8..0731adce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -208,7 +208,14 @@ "transferOwnership": "Przekaż własność", "transferDesc": "Przekaż rolę właściciela innemu użytkownikowi. Twoja rola zmieni się na edytora.", "transferBtn": "Przekaż", - "transferPrompt": "Wprowadź adres e-mail nowego właściciela:", + "transferModalTitle": "Przekaż własność", + "transferModalDesc": "Nowy właściciel przejmuje ten projekt wraz z jego przestrzenią w chmurze. Zachowasz dostęp jako edytor, a tylko nowy właściciel będzie mógł przekazać własność z powrotem.", + "transferSelectLabel": "Nowy właściciel", + "transferNoCandidates": "Nie ma komu przekazać tego projektu. Najpierw zaproś współpracownika.", + "transferring": "Przekazywanie...", + "confirmTransferBtn": "Przekaż własność", + "transferProRequired": "Nowy właściciel musi mieć aktywną subskrypcję Pro.", + "transferFailed": "Nie udało się przekazać własności. Spróbuj ponownie.", "deleteProject": "Usuń projekt", "deleteProjectDesc": "Po usunięciu projektu nie ma odwrotu. Upewnij się, że tego chcesz.", "deleteBtn": "Usuń", @@ -235,10 +242,13 @@ "deleteBtn": "Usuń", "deleteModalTitle": "Usuń konto", "deleteModalDesc": "To trwale usunie Twoje konto i wszystkie powiązane dane. Tej akcji nie można cofnąć.", + "deleteSubscriptionWarning": "Twoja subskrypcja Pro jest aktywna do {date}. Usunięcie konta anuluje ją natychmiast — pozostały czas przepada i nie zostanie zwrócony.", + "deleteSubscriptionWarningApple": "Twoja subskrypcja Pro jest rozliczana przez App Store i pozostaje aktywna do {date}. Usunięcie konta kończy dostęp Pro, ale nie zatrzymuje płatności — anuluj subskrypcję również w ustawieniach App Store.", "deleteConfirmPhrase": "Potwierdzam usunięcie mojego konta", "deleteConfirmLabel": "Wpisz poniższy tekst, aby potwierdzić usunięcie:", "deleting": "Usuwanie...", "deleteAccountBtn": "Usuń moje konto", + "deleteFailed": "Nie udało się usunąć konta. Spróbuj ponownie.", "subscription": { "openWebsite": "Zarządzaj na scriptio.app", "title": "Subskrypcja", diff --git a/messages/zh.json b/messages/zh.json index 0e52fce3..d8b53b6a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -208,7 +208,14 @@ "transferOwnership": "转移所有权", "transferDesc": "将所有者权限转移给其他用户。您将降级为编辑者。", "transferBtn": "转移", - "transferPrompt": "输入新所有者的邮箱:", + "transferModalTitle": "转移所有权", + "transferModalDesc": "新所有者将接管该项目及其云存储空间。您将以编辑者身份继续访问,只有新所有者才能将所有权转回。", + "transferSelectLabel": "新所有者", + "transferNoCandidates": "没有可转移的对象。请先邀请协作者。", + "transferring": "正在转移...", + "confirmTransferBtn": "确认转移所有权", + "transferProRequired": "新所有者需要有效的 Pro 订阅。", + "transferFailed": "转移所有权失败,请重试。", "deleteProject": "删除项目", "deleteProjectDesc": "删除后无法恢复,请谨慎操作。", "deleteBtn": "删除", @@ -235,10 +242,13 @@ "deleteBtn": "删除", "deleteModalTitle": "删除账户", "deleteModalDesc": "该操作不可撤销。", + "deleteSubscriptionWarning": "您的 Pro 订阅有效期至 {date}。删除账户将立即取消订阅,剩余时间将失效且不予退款。", + "deleteSubscriptionWarningApple": "您的 Pro 订阅通过 App Store 计费,有效期至 {date}。删除账户会终止 Pro 访问权限,但不会停止扣费——请同时在 App Store 设置中取消订阅。", "deleteConfirmPhrase": "我确认删除我的账户", "deleteConfirmLabel": "请输入以下文字以确认删除:", "deleting": "正在注销...", "deleteAccountBtn": "确认注销账户", + "deleteFailed": "删除账户失败,请重试。", "subscription": { "openWebsite": "在 scriptio.app 上管理", "title": "订阅", diff --git a/prisma/migrations/20260816123000_project_member_user_cascade/migration.sql b/prisma/migrations/20260816123000_project_member_user_cascade/migration.sql new file mode 100644 index 00000000..6a02a011 --- /dev/null +++ b/prisma/migrations/20260816123000_project_member_user_cascade/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "ProjectMember" DROP CONSTRAINT "ProjectMember_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "ProjectMember" ADD CONSTRAINT "ProjectMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8819666f..3868b958 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,7 +147,7 @@ model ProjectMember { id Int @id @default(autoincrement()) role ProjectRole @default(VIEWER) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String diff --git a/src/app/api/projects/[projectId]/route.ts b/src/app/api/projects/[projectId]/route.ts index b161cf49..075642a5 100644 --- a/src/app/api/projects/[projectId]/route.ts +++ b/src/app/api/projects/[projectId]/route.ts @@ -3,6 +3,7 @@ import { ProjectRole } from "../../../../generated/client/client"; import * as S3 from "@src/lib/s3"; import * as ProjectService from "@src/server/service/project-service"; import * as Roles from "@src/lib/utils/roles"; +import { destroyProjectCompletely } from "@src/server/service/project-teardown-service"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { ForbiddenError, @@ -106,21 +107,7 @@ async function deleteProject(req: NextRequest, { routeParams, user }: AuthApiCon throw new ForbiddenError(); } - // Reclaim the project's R2 assets before the cascade drops their tracking - // rows (afterwards we'd no longer know which objects to delete). - const assets = await ProjectService.listAssetHashes(projectId); - if (assets.length > 0) { - await S3.destroyMany(assets.map((a) => `assets/${projectId}/${a.hash}`)); - } - - const deleted = await ProjectService.destroy(projectId); - if (!deleted) { - throw new InternalServerError(); - } - - if (member.project.poster) { - S3.destroy(projectId); - } + await destroyProjectCompletely(projectId); return SuccessNoContent(); } diff --git a/src/app/api/projects/[projectId]/transfer-ownership/route.ts b/src/app/api/projects/[projectId]/transfer-ownership/route.ts new file mode 100644 index 00000000..70cd7d0b --- /dev/null +++ b/src/app/api/projects/[projectId]/transfer-ownership/route.ts @@ -0,0 +1,81 @@ +import { ProjectRole } from "../../../../../generated/client/client"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { + BodyFieldError, + ForbiddenError, + NotFoundError, + PaymentRequiredError, + ProjectNotFoundError, + Success, + validate, +} from "@src/lib/utils/api-utils"; +import { isProActive } from "@src/lib/utils/pro-utils"; + +import * as ProjectService from "@src/server/service/project-service"; +import * as UserService from "@src/server/service/user-service"; +import * as CollabUtils from "@src/lib/cloud/utils"; + +import z from "zod"; +import { NextRequest } from "next/server"; +import { TransferOwnershipSchema } from "@src/lib/utils/api-bodies"; +export type { TransferOwnershipBody } from "@src/lib/utils/api-bodies"; + +/** The role the outgoing owner keeps. Mirrors the copy in the danger zone. */ +const PREVIOUS_OWNER_ROLE = ProjectRole.EDITOR; + +const QuerySchema = z.object({ + projectId: z.string(), +}); + +/** + * POST `/projects/[projectId]/transfer-ownership` + * + * Hands the OWNER role to another member of the project. The caller is demoted + * to editor in the same transaction, so the project always has exactly one owner. + */ +async function transferOwnership(req: NextRequest, { routeParams, user }: AuthApiContext) { + const body = await req.json(); + const { userId: newOwnerId } = validate(TransferOwnershipSchema, body); + const { projectId } = validate(QuerySchema, routeParams); + + if (newOwnerId === user.id) throw new BodyFieldError("You already own this project"); + + const member = await ProjectService.getMembership(projectId, user.id); + if (!member) { + throw new ProjectNotFoundError(); + } + if (member.role !== ProjectRole.OWNER) { + throw new ForbiddenError("Only the owner can transfer ownership"); + } + + // Ownership can only move to an existing member: the new owner needs project + // access anyway, and going through the invite flow keeps that an explicit, + // accepted step rather than something the outgoing owner can force. + const newOwnerMembership = await ProjectService.getMembership(projectId, newOwnerId); + if (!newOwnerMembership) { + throw new NotFoundError("The new owner must be a member of this project"); + } + + // The owner is the quota holder for the project's cloud assets (see the asset + // upload route), so handing the project to a free account would silently break + // uploads for the whole team. + const newOwner = await UserService.getUserFromId(newOwnerId); + if (!newOwner) { + throw new NotFoundError("The new owner must be a member of this project"); + } + if (!isProActive(newOwner.isProUntil)) { + throw new PaymentRequiredError("The new owner needs an active Pro subscription"); + } + + await ProjectService.transferOwnership(projectId, user.id, newOwnerId, PREVIOUS_OWNER_ROLE); + + // Push both role changes to any live WS so the server-side write gate and the + // client UI flip immediately, instead of after a refresh (same reason as the + // member role PATCH). + await CollabUtils.notifyRoleChange(newOwnerId, projectId, ProjectRole.OWNER); + await CollabUtils.notifyRoleChange(user.id, projectId, PREVIOUS_OWNER_ROLE); + + return Success({ ownerId: newOwnerId, previousOwnerRole: PREVIOUS_OWNER_ROLE }); +} + +export const POST = apiHandler(transferOwnership); diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index 0a305894..920c4476 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -1,5 +1,6 @@ import { FAILED_USER_DELETION } from "@src/lib/messages"; -import { deleteUserFromId, getUserFromId } from "@src/server/service/user-service"; +import { getUserFromId } from "@src/server/service/user-service"; +import { deleteAccount } from "@src/server/service/account-deletion-service"; import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import * as UserService from "@src/server/service/user-service"; @@ -45,10 +46,12 @@ async function updateUser(req: NextRequest, { user }: AuthApiContext) { /** * DELETE `/users` * - * Deletes authenticated user + * Deletes the authenticated user along with everything attached to the account: + * owned cloud projects (DB rows, R2 assets and snapshots, Durable Object + * storage), collaborations, and email-keyed tokens. See account-deletion-service. */ async function deleteUser(req: NextRequest, { user }: AuthApiContext) { - const deleted = await deleteUserFromId(user.id); + const deleted = await deleteAccount(user.id); if (!deleted) { throw new InternalServerError(FAILED_USER_DELETION); } diff --git a/src/lib/cloud/index.ts b/src/lib/cloud/index.ts index cf6091da..f9e4fa30 100644 --- a/src/lib/cloud/index.ts +++ b/src/lib/cloud/index.ts @@ -37,12 +37,13 @@ const worker = { // segments: [projectId, ...rest] const doPath = "/" + segments.slice(1).join("/"); - // Authenticated API endpoints (saves, blacklist, allow, role-update) + // Authenticated API endpoints (saves, blacklist, allow, role-update, purge) const isAuthEndpoint = url.pathname.includes("/saves") || url.pathname.endsWith("/blacklist") || url.pathname.endsWith("/allow") || - url.pathname.endsWith("/role-update"); + url.pathname.endsWith("/role-update") || + url.pathname.endsWith("/purge"); if (isAuthEndpoint && request.method !== "GET") { const authHeader = request.headers.get("Authorization"); diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index cbb7991d..bf79f03c 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -107,24 +107,7 @@ export class ProjectRoom extends DurableObject { this.awareness.on("update", this.handleAwarenessUpdate); // Initialize database - this.ctx.storage.sql.exec(` - CREATE TABLE IF NOT EXISTS project ( - id INTEGER PRIMARY KEY CHECK (id = 1), - data BLOB - ); - CREATE TABLE IF NOT EXISTS blacklist ( - user_id TEXT PRIMARY KEY - ); - CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT - ); - CREATE TABLE IF NOT EXISTS snapshot_assets ( - snapshot_key TEXT, - hash TEXT - ); - CREATE INDEX IF NOT EXISTS idx_snapshot_assets_key ON snapshot_assets(snapshot_key); - `); + this.ensureSchema(); // Restore project state from SQLite. Attach the update handler AFTER // the restore so that re-loading persisted bytes on every DO wake-up @@ -196,6 +179,32 @@ export class ProjectRoom extends DurableObject { console.log(JSON.stringify({ event: "room_initialized" })); } + /** + * Create the SQLite schema. Idempotent — run on every construction, and + * again after a purge wipes the tables, so the room stays usable rather + * than throwing on the next statement. + */ + private ensureSchema(): void { + this.ctx.storage.sql.exec(` + CREATE TABLE IF NOT EXISTS project ( + id INTEGER PRIMARY KEY CHECK (id = 1), + data BLOB + ); + CREATE TABLE IF NOT EXISTS blacklist ( + user_id TEXT PRIMARY KEY + ); + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS snapshot_assets ( + snapshot_key TEXT, + hash TEXT + ); + CREATE INDEX IF NOT EXISTS idx_snapshot_assets_key ON snapshot_assets(snapshot_key); + `); + } + /** * Persist the current session state on the WebSocket so it survives * Cloudflare DO hibernation. Called whenever clientIds, userId, or role @@ -531,6 +540,11 @@ export class ProjectRoom extends DurableObject { return this.handleRename(key, name); } + // POST /purge — wipe the room (project or owner account deleted) + if (request.method === "POST" && url.pathname === "/purge") { + return this.handlePurge(); + } + // DELETE /saves — delete a save if (request.method === "DELETE" && url.pathname === "/saves") { const { key } = (await request.json()) as { key?: string }; @@ -1005,6 +1019,93 @@ export class ProjectRoom extends DurableObject { return new Response("Renamed", { status: 200 }); } + /** + * Wipe the room for good: every R2 snapshot under the project prefix, all + * Durable Object storage (live doc, blacklist, config, snapshot index, + * pending alarm) and every live connection. + * + * A DO is addressed by name, so nothing ever reclaims it on its own — the + * project's SQLite and its snapshots would outlive the project (and its + * owner's account) forever without this. + */ + private async handlePurge(): Promise { + // Drop connected clients first: an in-flight edit landing after the + // wipe would repopulate the doc we are about to delete. 4003 is the + // kick code — clients stop reconnecting and surface the + // project-unavailable dialog instead of retrying against a dead room. + for (const [socket] of this.sessions) { + try { + if (socket.readyState === 1) socket.close(4003, "Project deleted"); + } catch { + // Socket might already be closed + } + } + this.sessions.clear(); + this.userConnections.clear(); + + // Cancel pending save/snapshot work so nothing writes storage back. + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + this.isDirty = false; + + const deletedSnapshots = await this.deleteAllSnapshots(); + + await this.ctx.storage.deleteAlarm(); + await this.ctx.storage.deleteAll(); + this.alarmScheduled = false; + this.blacklist.clear(); + this.projectId = null; + + // deleteAll drops the SQL tables; recreate them and swap in an empty + // doc so a late reconnect (an unexpired cloud token) meets a blank + // room rather than a broken one — or our still-in-memory screenplay. + this.ensureSchema(); + this.resetDoc(); + + console.log(JSON.stringify({ event: "room_purged", deletedSnapshots })); + return Response.json({ deletedSnapshots }, { status: 200 }); + } + + /** Delete every snapshot stored for this project. Returns the count. */ + private async deleteAllSnapshots(): Promise { + if (!this.projectId) return 0; + + const bucket = (this.env as Env).SNAPSHOTS; + const prefix = `${this.projectId}/`; + let deleted = 0; + + // Re-list from the start on each pass rather than paginating with a + // cursor: everything listed is deleted before the next call, so the + // next page is always what's left. + for (;;) { + const listed = await bucket.list({ prefix, limit: 1000 }); + if (listed.objects.length === 0) break; + + await bucket.delete(listed.objects.map((o) => o.key)); + deleted += listed.objects.length; + + if (!listed.truncated) break; + } + + return deleted; + } + + /** Replace the live doc (and its awareness) with an empty one. */ + private resetDoc(): void { + this.doc.off("update", this.handleDocUpdate); + this.doc.destroy(); + this.awareness.destroy(); + + this.doc = new ProjectState(); + this.awareness = new awarenessProtocol.Awareness(this.doc); + clearInterval((this.awareness as unknown as { _checkInterval: ReturnType })._checkInterval); + this.awareness.setLocalState(null); + this.awareness.on("update", this.handleAwarenessUpdate); + this.doc.on("update", this.handleDocUpdate); + } + private async handleDeleteSave(key: string): Promise { // Validate key belongs to this project if (this.projectId && !key.startsWith(this.projectId + "/")) { diff --git a/src/lib/cloud/utils.ts b/src/lib/cloud/utils.ts index ea742242..86a8bc35 100644 --- a/src/lib/cloud/utils.ts +++ b/src/lib/cloud/utils.ts @@ -948,7 +948,8 @@ export class ThrottledWebsocketProvider extends WebsocketProvider { } } -export const allowOnWebsocket = async (userId: string, projectId: string) => { +/** POST a privileged action to a project's room, signed with an admin-action JWT. */ +const adminAction = async (projectId: string, path: string, body?: unknown): Promise => { const payload = { type: "admin-action", projectId, @@ -956,48 +957,43 @@ export const allowOnWebsocket = async (userId: string, projectId: string) => { const secret = new TextEncoder().encode(process.env.JWT_SECRET!); const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1m").sign(secret); - await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/allow`, { + return fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ userId }), + ...(body !== undefined && { body: JSON.stringify(body) }), }); }; -export const blacklistFromWebsocket = async (userId: string, projectId: string) => { - const payload = { - type: "admin-action", - projectId, - }; +export const allowOnWebsocket = async (userId: string, projectId: string) => { + await adminAction(projectId, "/allow", { userId }); +}; - const secret = new TextEncoder().encode(process.env.JWT_SECRET!); - const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1m").sign(secret); - await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/blacklist`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ userId }), - }); +export const blacklistFromWebsocket = async (userId: string, projectId: string) => { + await adminAction(projectId, "/blacklist", { userId }); }; export const notifyRoleChange = async (userId: string, projectId: string, role: string) => { - const payload = { - type: "admin-action", - projectId, - }; + await adminAction(projectId, "/role-update", { userId, role }); +}; - const secret = new TextEncoder().encode(process.env.JWT_SECRET!); - const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("1m").sign(secret); - await fetch(`${process.env.NEXT_PUBLIC_CLOUD_URL}/${projectId}/role-update`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ userId, role }), - }); +/** + * Wipe a project's Durable Object storage and every R2 snapshot it holds. + * Best-effort: a Worker outage must not block the DB-side deletion, so this + * reports failure instead of throwing (the caller logs and carries on). + */ +export const purgeProjectRoom = async (projectId: string): Promise => { + try { + const res = await adminAction(projectId, "/purge"); + if (!res.ok) { + console.error(`[cloud] Failed to purge room for project ${projectId}: ${res.status} ${await res.text()}`); + return false; + } + return true; + } catch (e) { + console.error(`[cloud] Failed to purge room for project ${projectId}:`, e); + return false; + } }; diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 17b8746e..9e0f61b1 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -2,6 +2,7 @@ import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, + ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; @@ -101,6 +102,56 @@ export const putObject = async ( } }; +/** + * Delete every object under a prefix (best-effort). + * + * Authoritative where `destroyMany` is not: it deletes what the bucket actually + * holds rather than what the database remembers, so objects whose tracking row + * was lost still get reclaimed. + * + * The prefix must end with "/" — a bare prefix would also match sibling keys + * that merely start with the same characters, and an empty one would target + * the whole bucket. + */ +export const destroyPrefix = async (prefix: string): Promise => { + if (!prefix.endsWith("/")) { + console.error(`Refusing to destroy S3 prefix "${prefix}": must end with "/"`); + return false; + } + + try { + let continuationToken: string | undefined; + + do { + const listed = await client.send( + new ListObjectsV2Command({ + Bucket: env.S3_BUCKET, + Prefix: prefix, + ContinuationToken: continuationToken, + }), + ); + + // ListObjectsV2 pages at 1000 keys, the same cap DeleteObjects takes. + const keys = listed.Contents?.flatMap((o) => (o.Key ? [{ Key: o.Key }] : [])) ?? []; + if (keys.length > 0) { + await client.send( + new DeleteObjectsCommand({ + Bucket: env.S3_BUCKET, + Delete: { Objects: keys }, + }), + ); + } + + continuationToken = listed.IsTruncated ? listed.NextContinuationToken : undefined; + } while (continuationToken); + + return true; + } catch (e) { + console.error("An error occurred while destroying an S3 prefix: ", e); + return false; + } +}; + /** Delete many objects at once (best-effort). No-op on an empty list. */ export const destroyMany = async (keys: string[]): Promise => { if (keys.length === 0) return true; diff --git a/src/lib/utils/api-bodies.ts b/src/lib/utils/api-bodies.ts index 899a6d95..8e46d2a1 100644 --- a/src/lib/utils/api-bodies.ts +++ b/src/lib/utils/api-bodies.ts @@ -34,6 +34,11 @@ export const UpdateRoleSchema = z.object({ }); export type UpdateRoleBody = z.infer; +export const TransferOwnershipSchema = z.object({ + userId: z.string().min(1), +}); +export type TransferOwnershipBody = z.infer; + const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/; export const UpdateUserBodySchema = z.object({ username: z.string().optional(), diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index 498095be..980f0dc1 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -5,6 +5,7 @@ import { UpdateProjectBody, UpdateRoleBody, RequestMagicLinkBody, + TransferOwnershipBody, UpdateUserBody, } from "./api-bodies"; import { apiFetch } from "@src/lib/api-client"; @@ -106,6 +107,10 @@ export const updateMemberRole = async (projectId: string, userId: string, body: return request(`/api/projects/${projectId}/members/${userId}`, "PATCH", body); }; +export const transferProjectOwnership = async (projectId: string, userId: string) => { + return request(`/api/projects/${projectId}/transfer-ownership`, "POST", { userId } satisfies TransferOwnershipBody); +}; + /* Users */ export const editUserSettings = (body: Partial) => { diff --git a/src/server/repository/magic-link-repository.ts b/src/server/repository/magic-link-repository.ts index 73c9e51f..033ecb2c 100644 --- a/src/server/repository/magic-link-repository.ts +++ b/src/server/repository/magic-link-repository.ts @@ -35,4 +35,8 @@ export class MagicLinkRepository { deleteByHash(tokenHash: string) { return prisma.magicLinkToken.deleteMany({ where: { tokenHash } }); } + + deleteByEmail(email: string) { + return prisma.magicLinkToken.deleteMany({ where: { email } }); + } } diff --git a/src/server/repository/project-repository.ts b/src/server/repository/project-repository.ts index 8ab9bb66..56a8f727 100644 --- a/src/server/repository/project-repository.ts +++ b/src/server/repository/project-repository.ts @@ -231,6 +231,22 @@ export class ProjectRepository { }); } + /** Hands the OWNER role to another member and demotes the current owner in + * one transaction — a half-applied swap would leave the project with two + * owners (or none), and the owner is the quota holder for every asset. */ + transferOwnership(projectId: string, currentOwnerId: string, newOwnerId: string, previousOwnerRole: ProjectRole) { + return prisma.$transaction([ + prisma.projectMember.update({ + where: { userId_projectId: { projectId, userId: currentOwnerId } }, + data: { role: previousOwnerRole }, + }), + prisma.projectMember.update({ + where: { userId_projectId: { projectId, userId: newOwnerId } }, + data: { role: ProjectRole.OWNER }, + }), + ]); + } + deleteProjectMember(projectId: string, userId: string) { return prisma.projectMember.delete({ where: { @@ -250,6 +266,20 @@ export class ProjectRepository { return prisma.projectMember.count({ where: { userId } }); } + /** Every membership of a user, with just what account deletion needs to + * tear each project down (no poster signing — unlike fetchProjectMemberships). */ + listMembershipsForTeardown(userId: string) { + return prisma.projectMember.findMany({ + where: { userId }, + select: { role: true, projectId: true }, + }); + } + + /** Pending invitations addressed to an email, across every project. */ + deleteInvitesByEmail(email: string) { + return prisma.projectInvitation.deleteMany({ where: { email } }); + } + fetchProjectById(projectId: string) { return prisma.project.findUnique({ where: { id: projectId }, diff --git a/src/server/repository/user-repository.ts b/src/server/repository/user-repository.ts index bae10d1f..45a1b0e5 100644 --- a/src/server/repository/user-repository.ts +++ b/src/server/repository/user-repository.ts @@ -60,6 +60,11 @@ export class UserRepository { }); } + /** Auth.js sign-in tokens, keyed by email rather than by a FK to User. */ + deleteVerificationTokens(email: string) { + return prisma.verificationToken.deleteMany({ where: { identifier: email } }); + } + fetchUser(idOrEmail: idOrEmailType) { return prisma.user.findUnique({ where: idOrEmail, diff --git a/src/server/service/account-deletion-service.ts b/src/server/service/account-deletion-service.ts new file mode 100644 index 00000000..2c66146d --- /dev/null +++ b/src/server/service/account-deletion-service.ts @@ -0,0 +1,86 @@ +/** + * Account deletion. + * + * Removes everything attached to a user, in an order that never strands data: + * 1. projects they OWN — Durable Object room, R2 snapshots, R2 assets/poster, + * DB rows. Nobody else can delete these afterwards (deletion needs the + * OWNER membership), so they must go before the account does. + * 2. projects they merely collaborate on — a room blacklist so a live socket + * is dropped instead of editing on with credentials that outlive the + * account (the membership row itself cascades in step 4). + * 3. rows keyed by email rather than by a FK to User (magic-link tokens, + * Auth.js verification tokens, pending invitations addressed to them). + * None of these can be a foreign key: all three are written for addresses + * that have no account yet — sign-up links and invitations to strangers. + * 4. the User row itself, which cascades Account, Session, Transaction and + * the remaining ProjectMember rows. + * + * External cleanup (Cloudflare, Stripe) is best-effort: it is logged on + * failure but never blocks the deletion, otherwise a Worker outage would leave + * the user unable to delete their account at all. + */ + +import Stripe from "stripe"; + +import * as CollabUtils from "@src/lib/cloud/utils"; +import * as MagicLinkService from "@src/server/service/magic-link-service"; +import * as ProjectService from "@src/server/service/project-service"; +import * as UserService from "@src/server/service/user-service"; +import { destroyProjectCompletely } from "@src/server/service/project-teardown-service"; +import { ProjectRole } from "@src/generated/client/client"; +import { logger } from "@src/lib/utils/logger"; + +/** + * Stop billing a user who no longer exists. Cancels immediately rather than at + * period end: the account is gone, so there is nothing left to keep active — + * and once the Transaction rows cascade away we can no longer map the + * subscription back to anyone. + */ +async function cancelStripeSubscription(userId: string): Promise { + const subscriptionId = await UserService.getStripeSubscriptionId(userId); + if (!subscriptionId) return; + + const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); + await stripe.subscriptions.cancel(subscriptionId); +} + +export async function deleteAccount(userId: string): Promise { + const user = await UserService.getUserFromId(userId); + if (!user) return false; + + const memberships = await ProjectService.getMembershipsForTeardown(userId); + + for (const membership of memberships) { + if (membership.role === ProjectRole.OWNER) { + await destroyProjectCompletely(membership.projectId); + } else { + // Blacklisting leaves the user id in that room's storage on + // purpose: their cloud token stays valid for up to an hour, and + // the WebSocket upgrade only checks the token — without the + // blacklist a deleted account could reconnect and keep editing. + await CollabUtils.blacklistFromWebsocket(userId, membership.projectId); + } + } + + // The webhook clears subscriptionProvider when a subscription ends, so a + // lingering STRIPE means we believe one is still live. Apple subscriptions + // can only be cancelled by the user through the App Store. + if (user.subscriptionProvider === "STRIPE") { + try { + await cancelStripeSubscription(userId); + } catch (e) { + logger.error("[AccountDeletion] Failed to cancel Stripe subscription", { userId, error: e }); + } + } + + await Promise.all([ + MagicLinkService.deleteForEmail(user.email), + UserService.deleteVerificationTokens(user.email), + ProjectService.deleteInvitesByEmail(user.email), + ]); + + await UserService.deleteUserFromId(userId); + logger.info("[AccountDeletion] Deleted account", { userId, projects: memberships.length }); + + return true; +} diff --git a/src/server/service/magic-link-service.ts b/src/server/service/magic-link-service.ts index ff419bb3..86d4bfb3 100644 --- a/src/server/service/magic-link-service.ts +++ b/src/server/service/magic-link-service.ts @@ -12,3 +12,5 @@ export const issue = (data: MagicLinkTokenCreation) => repository.create(data); export const findByHash = (tokenHash: string) => repository.findByHash(tokenHash); export const consumeByHash = (tokenHash: string) => repository.deleteByHash(tokenHash); + +export const deleteForEmail = (email: string) => repository.deleteByEmail(email); diff --git a/src/server/service/project-service.ts b/src/server/service/project-service.ts index aab0fe96..0352632c 100644 --- a/src/server/service/project-service.ts +++ b/src/server/service/project-service.ts @@ -36,6 +36,15 @@ export async function upsertMember(projectId: string, userId: string, role: Proj return repository.setProjectMember(projectId, userId, role); } +export async function transferOwnership( + projectId: string, + currentOwnerId: string, + newOwnerId: string, + previousOwnerRole: ProjectRole = ProjectRole.EDITOR, +) { + return repository.transferOwnership(projectId, currentOwnerId, newOwnerId, previousOwnerRole); +} + export async function createInvite(projectId: string, email: string, token: string) { return repository.createInvite(projectId, email, token); } @@ -68,6 +77,14 @@ export async function countMembershipsByUser(userId: string) { return repository.countMembershipsByUser(userId); } +export async function getMembershipsForTeardown(userId: string) { + return repository.listMembershipsForTeardown(userId); +} + +export async function deleteInvitesByEmail(email: string) { + return repository.deleteInvitesByEmail(email); +} + export async function searchProjects(term: string, limit: number, cursor?: number) { return repository.searchProjects(term, limit, cursor); } diff --git a/src/server/service/project-teardown-service.ts b/src/server/service/project-teardown-service.ts new file mode 100644 index 00000000..38e15b8a --- /dev/null +++ b/src/server/service/project-teardown-service.ts @@ -0,0 +1,31 @@ +/** + * Full teardown of a cloud project. + * + * A project's bytes live in three places, and none of them reclaim themselves: + * the Durable Object room (SQLite doc + snapshot index) and its R2 snapshots, + * the R2 asset objects (board images / audio) plus the poster, and the DB rows. + * Shared by the project DELETE route and account deletion so both wipe all three. + */ + +import * as S3 from "@src/lib/s3"; +import * as ProjectService from "@src/server/service/project-service"; +import * as CollabUtils from "@src/lib/cloud/utils"; + +export async function destroyProjectCompletely(projectId: string): Promise { + // Purge the room first: it holds the live doc and keeps snapshotting on an + // alarm, so a room left running could re-upload right after we clear R2. + await CollabUtils.purgeProjectRoom(projectId); + + // Wipe the whole asset folder rather than the hashes the DB knows about — + // an object whose tracking row was lost would otherwise stay forever. + await S3.destroyPrefix(`assets/${projectId}/`); + + // The poster lives outside that folder, under its own key. Deleted + // unconditionally: S3 deletes are idempotent, and `hasPoster` is not + // trustworthy enough to gate on (any PATCH without a poster resets the + // flag to false while the object stays in the bucket). + await S3.destroy(`poster-${projectId}`); + + // Cascades ProjectMember, ProjectInvitation and ProjectAsset rows. + await ProjectService.destroy(projectId); +} diff --git a/src/server/service/user-service.ts b/src/server/service/user-service.ts index 6cde07f0..77cb07dd 100644 --- a/src/server/service/user-service.ts +++ b/src/server/service/user-service.ts @@ -18,6 +18,10 @@ export const deleteUserFromId = async (userId: string) => { return repository.deleteUser({ id: userId }); }; +export const deleteVerificationTokens = async (email: string) => { + return repository.deleteVerificationTokens(email); +}; + export const getUserFromId = async (userId: string) => { return repository.fetchUser({ id: userId }); }; From f55218c20762725e11fce4afc391f0d7f8ee0a49 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Mon, 17 Aug 2026 16:51:56 +0200 Subject: [PATCH 2/6] fixed revision mode revised text, purge cloud room on project deletion --- .../projects/[projectId]/cloud-token/route.ts | 6 +- src/lib/cloud/room.ts | 134 ++++++++++++++++-- src/lib/cloud/types.ts | 19 +++ .../extensions/revisions-extension.ts | 63 +++++--- src/lib/screenplay/revisions.ts | 59 ++++++-- src/tests/repro/revisions-baseline.test.ts | 117 ++++++++++++++- 6 files changed, 355 insertions(+), 43 deletions(-) diff --git a/src/app/api/projects/[projectId]/cloud-token/route.ts b/src/app/api/projects/[projectId]/cloud-token/route.ts index 6b940aa0..f891ebcb 100644 --- a/src/app/api/projects/[projectId]/cloud-token/route.ts +++ b/src/app/api/projects/[projectId]/cloud-token/route.ts @@ -2,6 +2,7 @@ import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; import { ForbiddenError, Success, validate } from "@src/lib/utils/api-utils"; import * as ProjectService from "@src/server/service/project-service"; +import { CLOUD_TOKEN_TTL_MS } from "@src/lib/cloud/types"; import { SignJWT } from "jose"; import z from "zod"; @@ -24,10 +25,13 @@ async function projectCloudTokenRoute(req: NextRequest, { routeParams, user }: A role: member.role, }; + // Shared with the room's purge tombstone, which has to outlive every token + // still in circulation when a project is deleted — keep the two derived + // from one constant so they can never drift apart. const secret = new TextEncoder().encode(process.env.JWT_SECRET!); const token = await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("1h") + .setExpirationTime(Math.floor((Date.now() + CLOUD_TOKEN_TTL_MS) / 1000)) .sign(secret); return Success(token); diff --git a/src/lib/cloud/room.ts b/src/lib/cloud/room.ts index bf79f03c..5b244c13 100644 --- a/src/lib/cloud/room.ts +++ b/src/lib/cloud/room.ts @@ -14,6 +14,7 @@ import { RETENTION_DAY_MS, RETENTION_HOUR_MS, RETENTION_INTERVAL_30MIN_MS, + PURGE_TOMBSTONE_GRACE_MS, SessionInfo, SaveEntry, } from "./types"; @@ -36,6 +37,13 @@ export class ProjectRoom extends DurableObject { private projectId: string | null = null; private lastAwarenessCleanup: number = 0; + /** + * When this room was purged, or null while it is live. A purged room is a + * tombstone: it keeps its name alive only to refuse the connections that + * outlive the project, and does nothing else until it self-destructs. + */ + private purgedAt: number | null = null; + /** Project schema version of the in-memory doc; the gatekeeper compares * client-advertised versions against this on connect. */ private docVersion: number = CURRENT_PROJECT_VERSION; @@ -106,6 +114,18 @@ export class ProjectRoom extends DurableObject { // Track client IDs when awareness updates come from a WebSocket this.awareness.on("update", this.handleAwarenessUpdate); + // Read the tombstone before anything else touches storage. A purged + // room must not recreate its schema: `CREATE TABLE IF NOT EXISTS` is a + // write, and a Durable Object that holds storage never goes away — so + // rebuilding it on every wake-up would keep the room alive forever and + // defeat the self-destruct alarm. Everything below is skipped: the room + // has no doc, no sessions and no work to do, only connections to refuse. + this.purgedAt = this.readTombstone(); + if (this.purgedAt !== null) { + console.log(JSON.stringify({ event: "room_tombstone_loaded", purgedAt: this.purgedAt })); + return; + } + // Initialize database this.ensureSchema(); @@ -180,9 +200,28 @@ export class ProjectRoom extends DurableObject { } /** - * Create the SQLite schema. Idempotent — run on every construction, and - * again after a purge wipes the tables, so the room stays usable rather - * than throwing on the next statement. + * The purge timestamp if this room has been torn down, null otherwise. + * + * Reads without creating anything: a missing `config` table just means the + * room has never been opened, and the tombstoned case must leave storage + * exactly as the purge left it so the self-destruct alarm can empty it. + */ + private readTombstone(): number | null { + try { + const rows = this.ctx.storage.sql.exec("SELECT value FROM config WHERE key = 'purgedAt';").toArray(); + if (rows.length === 0) return null; + const purgedAt = Number(rows[0].value); + return Number.isFinite(purgedAt) ? purgedAt : null; + } catch { + // No `config` table — a room that was never initialized. + return null; + } + } + + /** + * Create the SQLite schema. Idempotent — run on every construction of a + * live room, so a fresh Durable Object is usable rather than throwing on + * the next statement. Never run on a tombstoned room. */ private ensureSchema(): void { this.ctx.storage.sql.exec(` @@ -271,6 +310,7 @@ export class ProjectRoom extends DurableObject { * Mark the document as dirty and schedule an R2 snapshot alarm. */ markDirty(): void { + if (this.purgedAt !== null) return; this.isDirty = true; this.scheduleSnapshotAlarm(); } @@ -279,6 +319,9 @@ export class ProjectRoom extends DurableObject { * Schedule a Cloudflare Alarm for R2 snapshot if not already pending. */ private async scheduleSnapshotAlarm(): Promise { + // Never overwrite the self-destruct alarm with a snapshot alarm — that + // would postpone the teardown indefinitely and re-snapshot a dead room. + if (this.purgedAt !== null) return; if (this.alarmScheduled) return; const currentAlarm = await this.ctx.storage.getAlarm(); if (currentAlarm) { @@ -295,6 +338,22 @@ export class ProjectRoom extends DurableObject { async alarm(): Promise { this.alarmScheduled = false; + // Self-destruct. The tombstone has now outlived every cloud token that + // could still have reached this room, so there is nothing left to + // refuse. Delete the last of the storage and write nothing back: a + // Durable Object that holds no storage stops existing, which is the + // only way a room addressed by name is ever reclaimed. + // + // `purgedAt` deliberately stays set. Storage is empty but this instance + // never ran `ensureSchema`, so it must keep refusing until it is + // evicted; a later incarnation of the same name reads no tombstone and + // starts clean. + if (this.purgedAt !== null) { + await this.ctx.storage.deleteAll(); + console.log(JSON.stringify({ event: "room_self_destructed", purgedAt: this.purgedAt })); + return; + } + if (!this.isDirty) return; this.isDirty = false; @@ -495,6 +554,22 @@ export class ProjectRoom extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); + // A tombstoned room refuses everything, before any code below can write + // to storage (`persistProjectId` would). This is the whole point of the + // tombstone: cloud tokens stay valid for an hour after the project row + // is deleted, and the WebSocket gate only checks the token — so a client + // that happened to be idle-disconnected during the purge would otherwise + // reconnect, find a blank room, and re-upload its local copy of the + // project into storage that nothing can ever reach or reclaim again. + if (this.purgedAt !== null) { + // A retried teardown must not re-arm the self-destruct clock, so + // answer purges idempotently instead of rejecting them. + if (request.method === "POST" && url.pathname === "/purge") { + return Response.json({ deletedSnapshots: 0, alreadyPurged: true }, { status: 200 }); + } + return new Response("Project deleted", { status: 410 }); + } + // Persist projectId from header (set by the outer worker fetch) const headerProjectId = request.headers.get("X-Project-Id"); if (headerProjectId) { @@ -1027,6 +1102,13 @@ export class ProjectRoom extends DurableObject { * A DO is addressed by name, so nothing ever reclaims it on its own — the * project's SQLite and its snapshots would outlive the project (and its * owner's account) forever without this. + * + * What survives is a tombstone and an alarm. Closing the live sockets only + * reaches clients that happen to be connected, and the client disconnects + * itself after 30s idle while its cloud token stays valid for an hour, so + * for that hour a reconnect could still land here and re-upload the project + * we just deleted. The tombstone refuses those; the alarm then deletes the + * tombstone once no valid token can exist anymore, leaving nothing behind. */ private async handlePurge(): Promise { // Drop connected clients first: an in-flight edit landing after the @@ -1058,13 +1140,33 @@ export class ProjectRoom extends DurableObject { this.blacklist.clear(); this.projectId = null; - // deleteAll drops the SQL tables; recreate them and swap in an empty - // doc so a late reconnect (an unexpired cloud token) meets a blank - // room rather than a broken one — or our still-in-memory screenplay. - this.ensureSchema(); + const purgedAt = Date.now(); + this.purgedAt = purgedAt; + + // Drop the screenplay from memory as well — otherwise the doc outlives + // the storage it was wiped from, still attached to the save pipeline. this.resetDoc(); - console.log(JSON.stringify({ event: "room_purged", deletedSnapshots })); + // Leave a tombstone. deleteAll dropped the schema, so recreate only the + // table it lives in: a purged room is not meant to be usable again, + // just to answer "gone" to the clients whose cloud tokens outlive the + // project. Once the last of those has expired the alarm deletes this + // final row, and the room — which nothing else would ever reclaim — + // ceases to exist. + this.ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT);"); + this.ctx.storage.sql.exec( + "INSERT OR REPLACE INTO config (key, value) VALUES ('purgedAt', ?);", + String(purgedAt), + ); + await this.ctx.storage.setAlarm(purgedAt + PURGE_TOMBSTONE_GRACE_MS); + + console.log( + JSON.stringify({ + event: "room_purged", + deletedSnapshots, + selfDestructAt: new Date(purgedAt + PURGE_TOMBSTONE_GRACE_MS).toISOString(), + }), + ); return Response.json({ deletedSnapshots }, { status: 200 }); } @@ -1123,6 +1225,19 @@ export class ProjectRoom extends DurableObject { // ---- WebSocket handlers ---- async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string): Promise { + // A socket that survived the purge (hibernated, so it was never in + // `sessions` to be closed) must not be answered: with no session to + // check a role against, the protocol would treat it as a writer and + // apply its doc updates. + if (this.purgedAt !== null) { + try { + if (ws.readyState === 1) ws.close(4003, "Project deleted"); + } catch { + // Socket might already be closed + } + return; + } + if (!(message instanceof ArrayBuffer)) return; const fullMessage = new Uint8Array(message); @@ -1133,6 +1248,7 @@ export class ProjectRoom extends DurableObject { } scheduleSave(): void { + if (this.purgedAt !== null) return; if (this.saveTimeout) { clearTimeout(this.saveTimeout); } @@ -1140,6 +1256,8 @@ export class ProjectRoom extends DurableObject { } async saveToDisk(): Promise { + // The schema is gone after a purge; writing would resurrect it. + if (this.purgedAt !== null) return; try { const fullDocState = Y.encodeStateAsUpdate(this.doc); this.ctx.storage.sql.exec("INSERT OR REPLACE INTO project (id, data) VALUES (1, ?);", fullDocState); diff --git a/src/lib/cloud/types.ts b/src/lib/cloud/types.ts index 6a0feb6a..7b00ed53 100644 --- a/src/lib/cloud/types.ts +++ b/src/lib/cloud/types.ts @@ -14,6 +14,25 @@ export const SNAPSHOT_INTERVAL_MS = 60_000; // 1 minute between R2 snapshots export const STALE_AWARENESS_TIMEOUT_MS = 60000; // 60 seconds export const AWARENESS_CLEANUP_INTERVAL_MS = 30000; // Check every 30 seconds +/** + * Lifetime of a project cloud token, issued by `/api/projects/[projectId]/cloud-token`. + * + * The WebSocket gate only verifies the JWT — it never re-reads the database — + * so a token keeps opening connections for this long after the project row is + * deleted. That window is what a purged room has to defend against. + */ +export const CLOUD_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour + +/** + * How long a purged room keeps its tombstone before self-destructing. + * + * The token TTL is the real deadline: once the last token issued before the + * deletion has expired, nothing can reach the room and there is nothing left to + * refuse. The extra hour is slack for clock skew between the app that signs + * tokens and the Worker that checks them. + */ +export const PURGE_TOMBSTONE_GRACE_MS = CLOUD_TOKEN_TTL_MS + 60 * 60 * 1000; + // Retention thresholds export const RETENTION_HOUR_MS = 60 * 60 * 1000; export const RETENTION_DAY_MS = 24 * RETENTION_HOUR_MS; diff --git a/src/lib/screenplay/extensions/revisions-extension.ts b/src/lib/screenplay/extensions/revisions-extension.ts index 69bc52b2..dc7aa176 100644 --- a/src/lib/screenplay/extensions/revisions-extension.ts +++ b/src/lib/screenplay/extensions/revisions-extension.ts @@ -5,6 +5,7 @@ import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; import { ySyncPluginKey } from "@tiptap/y-tiptap"; import { + DiffRun, REVISION_COLORS, REVISION_MARK, REVISION_STAMP_META, @@ -219,6 +220,30 @@ const textRevisions = (node: PMNode, rev: number): { self: boolean; prior?: numb return { self, prior }; }; +/** + * Text spans of a node already carrying revision `rev`'s "ins" mark, in line-local + * offsets, touching spans merged. + * + * What earlier stamping on this line concluded had been added — kept as alignment + * evidence for the rewrite that is about to drop it, since it covers the + * keystrokes that have already left `pending` (see {@link diffRuns}). + */ +const markedInsRuns = (node: PMNode, rev: number): DiffRun[] => { + const out: DiffRun[] = []; + node.descendants((child, off) => { + if (!child.isText) return true; + const marked = child.marks.some( + (m) => m.type.name === REVISION_MARK && m.attrs.index === rev && m.attrs.kind === "ins", + ); + if (!marked) return false; + const last = out[out.length - 1]; + if (last && last.to === off) last.to = off + child.nodeSize; + else out.push({ from: off, to: off + child.nodeSize }); + return false; + }); + return out; +}; + /** Stable `data-id`s of the top-level lines overlapping [from, to] in `doc`. */ const idsInSpan = (doc: PMNode, from: number, to: number): string[] => { const size = doc.content.size; @@ -477,26 +502,30 @@ const buildDerivedStampTransaction = ( return false; } - // Where the user's caret actually put text in this line, as a line-local - // offset. Comparing against the baseline says WHAT changed but cannot say - // which of several identical alignments the user meant; this is the other - // half of that answer, and it is already sitting in the pending set. Only - // covers edits from the current window — a line edited in an earlier flush - // falls back to the diff's own leftmost alignment, which is stable because - // an unambiguous run has only one alignment to choose from. - let anchor = -1; + // New line, or one already revised when the baseline was captured: the whole + // thing is this revision's, with no alignment to resolve. Otherwise, exactly + // the runs that differ — which is where `added` earns its keep. const nodeEnd = start + node.content.size; - for (const r of pending.ins) { - if (r.from >= start && r.from <= nodeEnd) { - anchor = r.from - start; - break; + let runs: DiffRun[]; + if (base === undefined || base.self) { + runs = [{ from: 0, to: text.length }]; + } else { + // Which of this line's characters are known to be this revision's, in the + // line's own offsets. Comparing against the baseline says WHAT changed but + // cannot say which of several identical alignments the user meant; this is + // the other half of that answer (see the note on `slideRun`), and it comes + // from both directions in time: the caret's own insertions, still sitting + // in the pending set, and the runs earlier flushes already marked — the + // keystrokes pending no longer remembers, which ProseMirror has been + // mapping forward for us and the rewrite below is about to drop. + const added = markedInsRuns(node, rev); + for (const r of pending.ins) { + const from = Math.max(r.from - start, 0); + const to = Math.min(r.to - start, text.length); + if (to > from) added.push({ from, to }); } + runs = diffRuns(base.text, text, added); } - - // New line, or one already revised when the baseline was captured: the - // whole thing is this revision's. Otherwise, exactly the runs that differ. - const runs = - base === undefined || base.self ? [{ from: 0, to: text.length }] : diffRuns(base.text, text, anchor); if (runs.length === 0) return false; // RECOMPUTE rather than accumulate: drop whatever an earlier flush wrote at diff --git a/src/lib/screenplay/revisions.ts b/src/lib/screenplay/revisions.ts index 7bbda6fd..9a58fbce 100644 --- a/src/lib/screenplay/revisions.ts +++ b/src/lib/screenplay/revisions.ts @@ -181,15 +181,33 @@ const DIFF_LIMIT = 400; * ambiguity is what smears a run off its word boundary, marking the "s" of "sits" * in "He [tands and s]its" instead of the "stands and " that was really typed. * - * `anchor` is where the user's caret actually inserted, in the same offsets as - * `next`, which resolves it exactly — the diff supplies what changed, the edit - * supplies where. With no anchor available, the leftmost equivalent alignment is - * the conventional choice (the same "shift the hunk up" rule diff tools use) and - * keeps runs on word boundaries far more often than the trim's rightmost. + * `added` resolves it: spans of `next` (in `next`'s own offsets) already known to + * hold characters this revision introduced — the caret's own insertions from the + * edits being stamped, plus what earlier stamping already marked on the line. The + * diff supplies what changed, these supply where. The run has to COVER them, and + * that containment is what makes the answer hold up over a word typed in several + * bursts: the diff then reports the whole word while the caret only accounts for + * its tail, so pinning the run's START to the caret would shunt it right by + * however much was typed earlier — colouring "[arrassemb]arrass" and leaving the + * "emb" the user typed reading as original text. Earlier marks close that gap + * from the left; ProseMirror has been mapping them forward all along. + * + * Where containment is impossible — the known-added span is wider than the run, + * because the same burst also deleted characters — the alignment with the most + * overlap is taken instead, which is the same answer for every case where it is + * possible. With nothing known, the leftmost equivalent alignment is the + * conventional choice (the same "shift the hunk up" rule diff tools use) and keeps + * runs on word boundaries far more often than the trim's rightmost. * * `leftBound`/`rightBound` keep a run from sliding into its neighbours. */ -const slideRun = (next: string, run: DiffRun, leftBound: number, rightBound: number, anchor: number): DiffRun => { +const slideRun = ( + next: string, + run: DiffRun, + leftBound: number, + rightBound: number, + added: readonly DiffRun[], +): DiffRun => { const len = run.to - run.from; // Slide left while the character before the run repeats its last character, // and right while the character after it repeats its first — the two moves @@ -198,7 +216,24 @@ const slideRun = (next: string, run: DiffRun, leftBound: number, rightBound: num while (lo > leftBound && next.charCodeAt(lo - 1) === next.charCodeAt(lo + len - 1)) lo--; let hi = run.from; while (hi + len < rightBound && next.charCodeAt(hi + len) === next.charCodeAt(hi)) hi++; - const at = anchor < 0 ? lo : Math.max(lo, Math.min(anchor, hi)); + if (lo === hi) return { from: lo, to: lo + len }; + + // Union of the known-added spans this run could possibly reach. Spans outside + // [lo, hi + len] belong to another run and must not drag this one. + let from = Infinity; + let to = -Infinity; + for (const span of added) { + if (span.to <= lo || span.from >= hi + len) continue; + if (span.from < from) from = span.from; + if (span.to > to) to = span.to; + } + if (from === Infinity) return { from: lo, to: lo + len }; + + // Cover that union: `at <= from` and `at + len >= to`. Both bounds are the + // same point whenever the union is the run (the normal case, so the alignment + // is exact); otherwise the lower is the leftmost maximum-overlap alignment, + // and clamping into [lo, hi] keeps the best one still reachable. + const at = Math.min(Math.max(Math.min(to - len, from), lo), hi); return { from: at, to: at + len }; }; @@ -218,8 +253,12 @@ const slideRun = (next: string, run: DiffRun, leftBound: number, rightBound: num * trimmed middle for the handful of short lines an edit touched, on the already * debounced flush — the common case never reaches it, because one edit leaves one * side of the trim empty. + * + * `added` is the alignment evidence described on {@link slideRun} — omit it and the + * runs still land somewhere that reconstructs `next`, just not necessarily on the + * copy of a repeated word the user really typed. */ -export const diffRuns = (prev: string, next: string, anchor = -1): DiffRun[] => { +export const diffRuns = (prev: string, next: string, added: readonly DiffRun[] = []): DiffRun[] => { if (prev === next) return []; const max = Math.min(prev.length, next.length); @@ -237,7 +276,7 @@ export const diffRuns = (prev: string, next: string, anchor = -1): DiffRun[] => // the limit → treat the whole region as rewritten. if (n === 0 || m === 0 || n > DIFF_LIMIT || m > DIFF_LIMIT) { const run = { from: p, to: p + m }; - return m > 0 ? [slideRun(next, run, 0, next.length, anchor)] : [run]; + return m > 0 ? [slideRun(next, run, 0, next.length, added)] : [run]; } // lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..]. @@ -298,7 +337,7 @@ export const diffRuns = (prev: string, next: string, anchor = -1): DiffRun[] => if (!r.pure || r.to === r.from) return { from: r.from, to: r.to }; const leftBound = k > 0 ? runs[k - 1].to : 0; const rightBound = k + 1 < runs.length ? runs[k + 1].from : next.length; - return slideRun(next, r, leftBound, rightBound, anchor); + return slideRun(next, r, leftBound, rightBound, added); }); }; diff --git a/src/tests/repro/revisions-baseline.test.ts b/src/tests/repro/revisions-baseline.test.ts index 5600a836..96a1d7d4 100644 --- a/src/tests/repro/revisions-baseline.test.ts +++ b/src/tests/repro/revisions-baseline.test.ts @@ -108,6 +108,16 @@ const markedTextOf = (editor: Editor, index: number): string => { return out; }; +/** Line-local offset where child `index`'s first "ins" mark starts, or -1. */ +const markStartOf = (editor: Editor, index: number): number => { + let start = -1; + editor.state.doc.child(index).descendants((child, off) => { + if (!child.isText || start >= 0) return; + if (child.marks.some((m) => m.type.name === "revision" && m.attrs.kind === "ins")) start = off; + }); + return start; +}; + const textOf = (editor: Editor, index: number): string => editor.state.doc.child(index).textContent; const deleteIn = (editor: Editor, index: number, from: number, to: number) => { @@ -171,12 +181,24 @@ describe("diffRuns", () => { it("marks the copy the caret actually inserted, not an identical neighbour", () => { // "Hey, it's you" + "it's " typed at offset 5. A prefix trim lands on the - // SECOND "it's"; the anchor puts it back on the one that was typed. + // SECOND "it's"; the known-added span puts it back on the one typed. const prev = "Hey, it's you"; const next = "Hey, it's it's you"; - expect(diffRuns(prev, next, 5)).toEqual([{ from: 5, to: 10 }]); + expect(diffRuns(prev, next, [{ from: 5, to: 10 }])).toEqual([{ from: 5, to: 10 }]); // Typed AFTER the existing one instead — same text, different intent. - expect(diffRuns(prev, next, 10)).toEqual([{ from: 10, to: 15 }]); + expect(diffRuns(prev, next, [{ from: 10, to: 15 }])).toEqual([{ from: 10, to: 15 }]); + }); + + it("covers a known-added span narrower than the run it belongs to", () => { + // The word was typed in bursts, so only its tail is still accounted for. + // Pinning the run's start to that tail shunts it right by the "emb" typed + // earlier, colouring "arrassemb" — the run has to COVER the span, not + // start at it. + const prev = "Sorry to embarrass you."; + const next = "Sorry to embarrassembarrass you."; + expect(diffRuns(prev, next, [{ from: 12, to: 18 }])).toEqual([{ from: 9, to: 18 }]); + // ...and the same tail belonging to the second copy pins it there instead. + expect(diffRuns(prev, next, [{ from: 21, to: 27 }])).toEqual([{ from: 18, to: 27 }]); }); it("keeps an inserted run on its word boundary", () => { @@ -184,17 +206,39 @@ describe("diffRuns", () => { // claiming the "s" of "sits". const prev = "He sits"; const next = "He stands and sits"; - expect(diffRuns(prev, next, 3)).toEqual([{ from: 3, to: 14 }]); + expect(diffRuns(prev, next, [{ from: 3, to: 14 }])).toEqual([{ from: 3, to: 14 }]); expect(next.slice(3, 14)).toBe("stands and "); + // Two bursts, "very " typed in front of an earlier "stands and ". Knowing + // only the second burst, the run covers it but keeps a character of slack — + // which of the two spaces is the new one is genuinely undecidable here... + const two = "He very stands and sits"; + expect(diffRuns(prev, two, [{ from: 3, to: 8 }])).toEqual([{ from: 2, to: 18 }]); + // ...and the first burst's own marked span settles it: exactly what was typed. + expect( + diffRuns(prev, two, [ + { from: 3, to: 8 }, + { from: 8, to: 19 }, + ]), + ).toEqual([{ from: 3, to: 19 }]); + expect(two.slice(3, 19)).toBe("very stands and "); + }); + + it("with nothing known added, prefers the leftmost equivalent alignment", () => { + expect(diffRuns("He sits", "He stands and sits")).toEqual([{ from: 2, to: 13 }]); }); - it("without an anchor, prefers the leftmost equivalent alignment", () => { - expect(diffRuns("He sits", "He stands and sits")).toEqual([{ from: 2, to: 13 }]); + it("ignores a known-added span belonging to another run", () => { + // Two edits, and only the second one's span is known — it must not drag the + // first run away from where the diff put it. + expect(diffRuns("aXbYc", "aQbRc", [{ from: 3, to: 4 }])).toEqual([ + { from: 1, to: 2 }, + { from: 3, to: 4 }, + ]); }); it("does not slide a run that replaced text rather than only adding", () => { // "cat" → "dog" is pinned by what it replaced; nothing to disambiguate. - expect(diffRuns("the cat sat", "the dog sat", 4)).toEqual([{ from: 4, to: 7 }]); + expect(diffRuns("the cat sat", "the dog sat", [{ from: 4, to: 7 }])).toEqual([{ from: 4, to: 7 }]); }); it("falls back to one coarse run when the changed region is a rewrite", () => { @@ -312,6 +356,65 @@ describe("revisions: marks are derived from the revision's baseline", () => { expect(markStart).toBe(5); }); + it("colours the typed copy when the duplicate word spans two flush windows", async () => { + const { editor, rev, capture } = makeEditor([LINES[0], "Sorry to embarrass you."]); + rev.enabled = true; + rev.current = 1; + capture(1); + + // A pause mid-word splits the typing across two flushes, so the second one + // sees a run (the whole word) far wider than the keystrokes still sitting + // in `pending` — and every alignment of it rebuilds the same sentence. + insertIn(editor, 1, 9, "emb"); + await settle(); + expect(markedTextOf(editor, 1)).toBe("emb"); + + insertIn(editor, 1, 12, "arrass"); + await settle(); + + expect(textOf(editor, 1)).toBe("Sorry to embarrassembarrass you."); + // The word the caret typed, whole — not "arrassemb", which leaves the "emb" + // it started with reading as original text. + expect(markedTextOf(editor, 1)).toBe("embarrass"); + expect(markStartOf(editor, 1)).toBe(9); + }); + + it("colours the typed copy when the duplicate word is typed AFTER the original", async () => { + const { editor, rev, capture } = makeEditor([LINES[0], "Sorry to embarrass you."]); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Same final sentence, opposite intent: the second copy is the new one. + insertIn(editor, 1, 18, "emb"); + await settle(); + insertIn(editor, 1, 21, "arrass"); + await settle(); + + expect(textOf(editor, 1)).toBe("Sorry to embarrassembarrass you."); + expect(markedTextOf(editor, 1)).toBe("embarrass"); + expect(markStartOf(editor, 1)).toBe(18); + }); + + it("keeps a phrase typed in front of an earlier one on its word boundary", async () => { + const { editor, rev, capture } = makeEditor([LINES[0], "He sits"]); + rev.enabled = true; + rev.current = 1; + capture(1); + + // "stands and " first, then "very " typed in front of it in a later flush: + // the accumulated run reaches one character further left than the words + // that were actually typed. + insertIn(editor, 1, 3, "stands and "); + await settle(); + insertIn(editor, 1, 3, "very "); + await settle(); + + expect(textOf(editor, 1)).toBe("He very stands and sits"); + expect(markedTextOf(editor, 1)).toBe("very stands and "); + expect(markStartOf(editor, 1)).toBe(3); + }); + it("keeps a typed phrase on its word boundary", async () => { const { editor, rev, capture } = makeEditor([LINES[0], "He sits"]); rev.enabled = true; From 5ed3801489bfc8d9fd94c00f69dd7e7ca06aaa3b Mon Sep 17 00:00:00 2001 From: Lycoon Date: Mon, 17 Aug 2026 18:30:22 +0200 Subject: [PATCH 3/6] added personal data extraction feature, updated privacy page content --- .../account/ProfileSettings.module.css | 41 + .../dashboard/account/ProfileSettings.tsx | 54 +- .../home/privacy/PrivacyContent.tsx | 24 +- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/ja.json | 9 +- messages/ko.json | 9 +- messages/pl.json | 9 +- messages/zh.json | 9 +- next.config.ts | 4 +- package-lock.json | 1083 +++++------------ package.json | 2 +- .../20260817100000_data_export/migration.sql | 21 + prisma/schema.prisma | 23 + src/app/api/users/export/route.ts | 21 + src/lib/mail/mail.ts | 6 + src/lib/s3.ts | 7 +- src/lib/utils/requests.ts | 4 + .../repository/data-export-repository.ts | 38 + src/server/repository/project-repository.ts | 22 + .../service/account-deletion-service.ts | 3 + src/server/service/gdpr-export-service.ts | 105 ++ src/server/service/project-service.ts | 4 + 25 files changed, 743 insertions(+), 791 deletions(-) create mode 100644 prisma/migrations/20260817100000_data_export/migration.sql create mode 100644 src/app/api/users/export/route.ts create mode 100644 src/server/repository/data-export-repository.ts create mode 100644 src/server/service/gdpr-export-service.ts diff --git a/components/dashboard/account/ProfileSettings.module.css b/components/dashboard/account/ProfileSettings.module.css index 4241265c..552daa72 100644 --- a/components/dashboard/account/ProfileSettings.module.css +++ b/components/dashboard/account/ProfileSettings.module.css @@ -119,6 +119,47 @@ border: 1px solid rgba(239, 68, 68, 0.2); } +/* GDPR data export — sits above the danger zone with the same row geometry, + but neutral colors: requesting a copy of your data is not a danger action. */ +.exportSection { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 16px; +} + +.exportContainer { + border: 2px dashed var(--separator); + border-radius: 8px; + overflow: hidden; +} + +.exportBtn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + background-color: var(--primary-text); + color: var(--main-bg); + border: none; + padding: 8px 16px; + border-radius: 40px; + cursor: pointer; + font-weight: 600; + transition: opacity 0.2s; + min-width: 110px; + margin-left: 30px; +} + +.exportBtn:hover { + opacity: 0.85; +} + +.exportBtn:disabled { + opacity: 0.5; + cursor: default; +} + /* Shown in the delete-account dialog when a live subscription is at stake */ .subscriptionWarning { display: flex; diff --git a/components/dashboard/account/ProfileSettings.tsx b/components/dashboard/account/ProfileSettings.tsx index 475b2710..f48259d6 100644 --- a/components/dashboard/account/ProfileSettings.tsx +++ b/components/dashboard/account/ProfileSettings.tsx @@ -1,11 +1,11 @@ "use client"; import { useEffect, useState } from "react"; -import { editUserInfo, deleteUser } from "@src/lib/utils/requests"; +import { editUserInfo, deleteUser, requestDataExport } from "@src/lib/utils/requests"; import { signOut } from "next-auth/react"; import { isTauri } from "@tauri-apps/api/core"; import { useRouter } from "next/navigation"; -import { ArrowRight, Trash2, Save, TriangleAlert } from "lucide-react"; +import { ArrowRight, Download, Trash2, Save, TriangleAlert } from "lucide-react"; import { useTranslations } from "next-intl"; import form from "./../../utils/Form.module.css"; @@ -59,6 +59,10 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [exportLoading, setExportLoading] = useState(false); + const [exportMessage, setExportMessage] = useState<{ type: "success" | "error"; text: string } | null>( + null, + ); // Sync state when settings load useEffect(() => { @@ -81,6 +85,28 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; setMessage(null); }; + // GDPR data-access request: the server bundles everything in the background + // and emails a download link, so the only feedback here is "check your inbox". + const handleRequestExport = async () => { + if (exportLoading) return; + setExportLoading(true); + setExportMessage(null); + try { + const res = await requestDataExport(); + if (res.ok) { + setExportMessage({ type: "success", text: t("exportRequested") }); + } else if (res.status === 409) { + setExportMessage({ type: "error", text: t("exportPending") }); + } else { + setExportMessage({ type: "error", text: t("exportFailed") }); + } + } catch { + setExportMessage({ type: "error", text: t("exportFailed") }); + } finally { + setExportLoading(false); + } + }; + const handleDeleteAccount = async () => { setDeleteLoading(true); setDeleteError(null); @@ -135,6 +161,30 @@ const ProfileSettings = ({ dangerOpen, onDangerToggle }: { dangerOpen: boolean; if (dangerOpen) { return ( <> +
+
+
+
+

{t("exportData")}

+

{t("exportDataDesc")}

+
+ +
+
+ {exportMessage && ( +
+ {exportMessage.text} +
+ )} +
+
diff --git a/landing/components/home/privacy/PrivacyContent.tsx b/landing/components/home/privacy/PrivacyContent.tsx index dd831818..33497a52 100644 --- a/landing/components/home/privacy/PrivacyContent.tsx +++ b/landing/components/home/privacy/PrivacyContent.tsx @@ -59,8 +59,11 @@ export default function PrivacyContent() {

Information we collect

We limit collection to the minimum required to deliver the service, including account and - project information. When you create an account (required for browser use and optional - for desktop sync) we collect your e-mail address and a password. + project information. Scriptio® is passwordless — we do not store passwords or any other + credentials. Signing in is entirely optional, on desktop, mobile and in the browser alike, and + is only needed to enable cloud synchronization and collaboration features. The only sign-in + methods available are a magic-link sent to your e-mail address and OAuth sign-in with Apple or + Google.

Screenplays, user preferences, boards, notes and other creative content you create or edit in @@ -105,9 +108,10 @@ export default function PrivacyContent() { We apply reasonable technical and organizational measures to protect your personal data and project content from unauthorized access, alteration, disclosure or destruction. These measures include industry-standard protections such as encrypted communications (TLS) for data in transit - and secure storage practices for data at rest. Passwords are stored using strong hashing - algorithms. In the unlikely event of a security breach affecting personal data, we will follow - applicable law and promptly notify affected users and authorities where required. + and secure storage practices for data at rest. As Scriptio® is passwordless, we never store + passwords or other credentials that could be compromised. In the unlikely event of a security + breach affecting personal data, we will follow applicable law and promptly notify affected + users and authorities where required.

@@ -120,6 +124,12 @@ export default function PrivacyContent() { Project content stored in the cloud will be deleted upon request, subject to any backup or legal hold obligations. Feel free to reach out to us through our contact form

+

+ When an invited user uploads assets to a shared project, those assets become part of the + project owner workspace storage quota. Deleting your personal user account will erase + your personal profile and account credentials, but assets uploaded to shared projects will + remain part of the project workspace to prevent breaking collaborative work. +

@@ -133,7 +143,9 @@ export default function PrivacyContent() { on consent.

- To exercise these rights or for account deletion/export requests, please contact us at: + Once authenticated, you can reclaim a copy of your personal data or delete your account + directly from your profile section within the app. If you are unable to access your account, + you may request access to or deletion of your personal data by contacting us at: contact@scriptio.app. We will respond within the timeframe required by applicable law.

diff --git a/messages/de.json b/messages/de.json index a4fb8888..6d6383e1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -238,6 +238,13 @@ "errorSaving": "Fehler beim Speichern", "saving": "Speichern...", "dangerZoneTitle": "Gefahrenzone", + "exportData": "Meine Daten exportieren", + "exportDataDesc": "Fordern Sie eine Kopie Ihrer persönlichen Daten an: Kontoinformationen, Einstellungen und Projektmitgliedschaften. Sie erhalten einen Download-Link per E-Mail, gültig für 7 Tage.", + "exportBtn": "Export anfordern", + "exportRequesting": "Wird angefordert...", + "exportRequested": "Export gestartet — Sie erhalten in Kürze eine E-Mail mit einem Download-Link. Der Link ist 7 Tage gültig.", + "exportPending": "Ein Export wird bereits vorbereitet. Bitte warten Sie auf die E-Mail.", + "exportFailed": "Der Export konnte nicht angefordert werden. Bitte versuchen Sie es erneut.", "deleteAccount": "Konto löschen", "deleteAccountDesc": "Löschen Sie dauerhaft Ihr Konto und alle verknüpften Daten. Dies kann nicht rückgängig gemacht werden.", "deleteBtn": "Löschen", @@ -728,4 +735,4 @@ "monthsAgo": "Vor {months, plural, one {# Monat} other {# Monaten}}", "moreThanYearAgo": "Vor über einem Jahr" } -} \ No newline at end of file +} diff --git a/messages/en.json b/messages/en.json index 1fc7cc79..7ad1fa82 100644 --- a/messages/en.json +++ b/messages/en.json @@ -237,6 +237,13 @@ "errorSaving": "An error occurred while saving", "saving": "Saving...", "dangerZoneTitle": "Danger zone", + "exportData": "Export my data", + "exportDataDesc": "Request a copy of your personal data: account information, settings and project memberships. You'll receive a download link by email, valid for 7 days.", + "exportBtn": "Request export", + "exportRequesting": "Requesting...", + "exportRequested": "Export started — you'll receive an email with a download link shortly. The link stays valid for 7 days.", + "exportPending": "An export is already being prepared. Please wait for the email.", + "exportFailed": "Failed to request the export. Please try again.", "deleteAccount": "Delete account", "deleteAccountDesc": "Permanently delete your account and all associated data. This cannot be undone.", "deleteBtn": "Delete", @@ -727,4 +734,4 @@ "monthsAgo": "{months, plural, one {# month ago} other {# months ago}}", "moreThanYearAgo": "More than 1 year ago" } -} \ No newline at end of file +} diff --git a/messages/es.json b/messages/es.json index 6e461006..5654b9e9 100644 --- a/messages/es.json +++ b/messages/es.json @@ -237,6 +237,13 @@ "errorSaving": "Ocurrió un error al guardar", "saving": "Guardando...", "dangerZoneTitle": "Zona de peligro", + "exportData": "Exportar mis datos", + "exportDataDesc": "Solicita una copia de tus datos personales: información de la cuenta, configuración y membresías de proyectos. Recibirás un enlace de descarga por correo, válido durante 7 días.", + "exportBtn": "Solicitar exportación", + "exportRequesting": "Solicitando...", + "exportRequested": "Exportación iniciada — pronto recibirás un correo con un enlace de descarga. El enlace es válido durante 7 días.", + "exportPending": "Ya se está preparando una exportación. Espera el correo electrónico.", + "exportFailed": "No se pudo solicitar la exportación. Inténtalo de nuevo.", "deleteAccount": "Eliminar cuenta", "deleteAccountDesc": "Elimina permanentemente tu cuenta y todos los datos asociados. Esto no se puede deshacer.", "deleteBtn": "Eliminar", @@ -727,4 +734,4 @@ "monthsAgo": "Hace {months, plural, one {# mes} other {# meses}}", "moreThanYearAgo": "Hace más de 1 año" } -} \ No newline at end of file +} diff --git a/messages/fr.json b/messages/fr.json index 8a7cd14a..18f90168 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -238,6 +238,13 @@ "errorSaving": "Une erreur est survenue lors de l'enregistrement", "saving": "Enregistrement...", "dangerZoneTitle": "Zone dangereuse", + "exportData": "Exporter mes données", + "exportDataDesc": "Demandez une copie de vos données personnelles : informations du compte, paramètres et participations aux projets. Vous recevrez un lien de téléchargement par e-mail, valide 7 jours.", + "exportBtn": "Demander l'export", + "exportRequesting": "Demande en cours...", + "exportRequested": "Export lancé — vous recevrez bientôt un e-mail avec un lien de téléchargement. Le lien reste valide 7 jours.", + "exportPending": "Un export est déjà en cours de préparation. Veuillez attendre l'e-mail.", + "exportFailed": "Échec de la demande d'export. Veuillez réessayer.", "deleteAccount": "Supprimer le compte", "deleteAccountDesc": "Supprimez définitivement votre compte et toutes les données associées. Cette action est irréversible.", "deleteBtn": "Supprimer", @@ -728,4 +735,4 @@ "monthsAgo": "Il y a {months, plural, one {# mois} other {# mois}}", "moreThanYearAgo": "Il y a plus d'un an" } -} \ No newline at end of file +} diff --git a/messages/ja.json b/messages/ja.json index 58a7cb40..85632cd9 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -237,6 +237,13 @@ "errorSaving": "保存中にエラーが発生しました", "saving": "保存中...", "dangerZoneTitle": "危険ゾーン", + "exportData": "データをエクスポート", + "exportDataDesc": "個人データ(アカウント情報、設定、プロジェクトのメンバーシップ)のコピーをリクエストできます。ダウンロードリンク(7日間有効)がメールで届きます。", + "exportBtn": "エクスポートを申請", + "exportRequesting": "申請中...", + "exportRequested": "エクスポートを開始しました。まもなくダウンロードリンクを記載したメールが届きます。リンクは7日間有効です。", + "exportPending": "エクスポートは既に準備中です。メールをお待ちください。", + "exportFailed": "エクスポートの申請に失敗しました。もう一度お試しください。", "deleteAccount": "アカウントを削除", "deleteAccountDesc": "アカウントとすべての関連データを完全に削除します。この操作は取り消せません。", "deleteBtn": "削除", @@ -727,4 +734,4 @@ "monthsAgo": "{months}ヶ月前", "moreThanYearAgo": "1年以上前" } -} \ No newline at end of file +} diff --git a/messages/ko.json b/messages/ko.json index 054c3a65..57cd26ee 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -237,6 +237,13 @@ "errorSaving": "저장 중 오류 발생", "saving": "저장 중...", "dangerZoneTitle": "위험 구역", + "exportData": "내 데이터 내보내기", + "exportDataDesc": "개인 데이터(계정 정보, 설정, 프로젝트 멤버십)의 사본을 요청합니다. 7일간 유효한 다운로드 링크가 이메일로 전송됩니다.", + "exportBtn": "내보내기 요청", + "exportRequesting": "요청 중...", + "exportRequested": "내보내기가 시작되었습니다. 곧 다운로드 링크가 포함된 이메일이 도착합니다. 링크는 7일간 유효합니다.", + "exportPending": "이미 내보내기가 준비 중입니다. 이메일을 기다려 주세요.", + "exportFailed": "내보내기 요청에 실패했습니다. 다시 시도해 주세요.", "deleteAccount": "계정 삭제", "deleteAccountDesc": "계정과 모든 데이터를 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다.", "deleteBtn": "삭제", @@ -727,4 +734,4 @@ "monthsAgo": "{months}달 전", "moreThanYearAgo": "1년 이상 전" } -} \ No newline at end of file +} diff --git a/messages/pl.json b/messages/pl.json index 0731adce..fe932053 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -237,6 +237,13 @@ "errorSaving": "Wystąpił błąd podczas zapisywania", "saving": "Zapisywanie...", "dangerZoneTitle": "Strefa niebezpieczeństwa", + "exportData": "Eksportuj moje dane", + "exportDataDesc": "Poproś o kopię swoich danych osobowych: informacji o koncie, ustawień i członkostw w projektach. Otrzymasz link do pobrania e-mailem, ważny przez 7 dni.", + "exportBtn": "Poproś o eksport", + "exportRequesting": "Wysyłanie prośby...", + "exportRequested": "Eksport rozpoczęty — wkrótce otrzymasz e-mail z linkiem do pobrania. Link jest ważny przez 7 dni.", + "exportPending": "Eksport jest już przygotowywany. Poczekaj na e-mail.", + "exportFailed": "Nie udało się zażądać eksportu. Spróbuj ponownie.", "deleteAccount": "Usuń konto", "deleteAccountDesc": "Trwale usuń swoje konto i wszystkie powiązane dane. Tej akcji nie można cofnąć.", "deleteBtn": "Usuń", @@ -727,4 +734,4 @@ "monthsAgo": "{months, plural, one {# miesiąc} few {# miesiące} many {# miesięcy} other {# miesięcy}} temu", "moreThanYearAgo": "Ponad rok temu" } -} \ No newline at end of file +} diff --git a/messages/zh.json b/messages/zh.json index d8b53b6a..74e2c228 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -237,6 +237,13 @@ "errorSaving": "保存时出错", "saving": "正在保存...", "dangerZoneTitle": "危险区域", + "exportData": "导出我的数据", + "exportDataDesc": "申请获取您的个人数据副本:账户信息、设置及项目成员资格。下载链接将通过邮件发送,有效期 7 天。", + "exportBtn": "申请导出", + "exportRequesting": "申请中...", + "exportRequested": "导出已开始 — 您很快会收到包含下载链接的邮件。链接有效期为 7 天。", + "exportPending": "已有导出正在准备中,请等待邮件。", + "exportFailed": "导出申请失败,请重试。", "deleteAccount": "注销账户", "deleteAccountDesc": "永久删除账户及数据。不可恢复。", "deleteBtn": "删除", @@ -727,4 +734,4 @@ "monthsAgo": "{months} 个月前", "moreThanYearAgo": "1 年前" } -} \ No newline at end of file +} diff --git a/next.config.ts b/next.config.ts index cd3091d4..b0bf2761 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,9 @@ const isTauriBuild = process.env.TAURI_BUILD === "true"; const config: NextConfig = { reactStrictMode: true, - serverExternalPackages: ["@prisma/client", "prisma"], + // fflate must stay unbundled server-side: its async zip API spawns + // worker_threads from stringified module code, which bundling would break. + serverExternalPackages: ["@prisma/client", "prisma", "fflate"], turbopack: { rules: { "*.svg": { diff --git a/package-lock.json b/package-lock.json index a51b9ff5..5d774c60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.113.0", - "@aws-sdk/s3-request-presigner": "^3.952.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@formkit/auto-animate": "^0.7.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.7.0", @@ -163,87 +163,11 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/crc32c": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", - "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", - "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -259,6 +183,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -271,6 +196,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -284,6 +210,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -297,6 +224,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -311,6 +239,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -320,6 +249,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -331,6 +261,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -343,6 +274,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -356,6 +288,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -365,133 +298,55 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-s3": { - "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.988.0.tgz", - "integrity": "sha512-mt7AdkieJJ5hEKeCxH4sdTTd679shUjo/cUvNY0fUHgQIPZa1jRuekTXnRytRrEwdrZWJDx56n1S8ism2uX7jg==", + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.28.tgz", + "integrity": "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/credential-provider-node": "^3.972.7", - "@aws-sdk/middleware-bucket-endpoint": "^3.972.3", - "@aws-sdk/middleware-expect-continue": "^3.972.3", - "@aws-sdk/middleware-flexible-checksums": "^3.972.6", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-location-constraint": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-sdk-s3": "^3.972.8", - "@aws-sdk/middleware-ssec": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.8", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/signature-v4-multi-region": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.988.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.6", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", - "@smithy/eventstream-serde-browser": "^4.2.8", - "@smithy/eventstream-serde-config-resolver": "^4.3.8", - "@smithy/eventstream-serde-node": "^4.2.8", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-blob-browser": "^4.2.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/hash-stream-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/md5-js": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/middleware-retry": "^4.4.31", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.30", - "@smithy/util-defaults-mode-node": "^4.2.33", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.8", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-ses": { - "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.988.0.tgz", - "integrity": "sha512-aai/H+AE1snoAmCWKzJt3DbywxYycd2cQasPMwZVUmq9KhSvSDM8IVA2sStVNKpiSmdDmRUrzh9Obvd/LQIrmw==", - "dev": true, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1111.0.tgz", + "integrity": "sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/credential-provider-node": "^3.972.7", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.8", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.988.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.6", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/middleware-retry": "^4.4.31", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.30", - "@smithy/util-defaults-mode-node": "^4.2.33", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.8", + "@aws-sdk/checksums": "^3.1000.28", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/middleware-sdk-s3": "^3.972.74", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso": { + "node_modules/@aws-sdk/client-ses": { "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.988.0.tgz", - "integrity": "sha512-ThqQ7aF1k0Zz4yJRwegHw+T1rM3a7ZPvvEUSEdvn5Z8zTeWgJAbtqW/6ejPsMLmFOlHgNcwDQN/e69OvtEOoIQ==", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.988.0.tgz", + "integrity": "sha512-aai/H+AE1snoAmCWKzJt3DbywxYycd2cQasPMwZVUmq9KhSvSDM8IVA2sStVNKpiSmdDmRUrzh9Obvd/LQIrmw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.8", + "@aws-sdk/credential-provider-node": "^3.972.7", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", @@ -526,6 +381,7 @@ "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", "tslib": "^2.6.2" }, "engines": { @@ -533,52 +389,43 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.8.tgz", - "integrity": "sha512-WeYJ2sfvRLbbUIrjGMUXcEHGu5SJk53jz3K9F8vFP42zWyROzPJ2NB6lMu9vWl5hnMwzwabX7pJc9Euh3JyMGw==", + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.4", - "@smithy/core": "^3.23.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.972.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz", - "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==", + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.6.tgz", - "integrity": "sha512-+dYEBWgTqkQQHFUllvBL8SLyXyLKWdxLMD1LmKJRvmb0NMJuaJFG/qg78C+LE67eeGbipYcE+gJ48VlLBGHlMw==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -586,20 +433,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.8.tgz", - "integrity": "sha512-z3QkozMV8kOFisN2pgRag/f0zPDrw96mY+ejAM0xssV/+YQ2kklbylRNI/TcTQUDnGg0yPxNjyV6F2EM2zPTwg==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/types": "^3.973.1", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/util-stream": "^4.5.12", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -607,24 +451,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.6.tgz", - "integrity": "sha512-6tkIYFv3sZH1XsjQq+veOmx8XWRnyqTZ5zx/sMtdu/xFRIzrJM1Y2wAXeCJL1rhYSB7uJSZ1PgALI2WVTj78ow==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/credential-provider-env": "^3.972.6", - "@aws-sdk/credential-provider-http": "^3.972.8", - "@aws-sdk/credential-provider-login": "^3.972.6", - "@aws-sdk/credential-provider-process": "^3.972.6", - "@aws-sdk/credential-provider-sso": "^3.972.6", - "@aws-sdk/credential-provider-web-identity": "^3.972.6", - "@aws-sdk/nested-clients": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -632,18 +475,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.6.tgz", - "integrity": "sha512-LXsoBoaTSGHdRCQXlWSA0CHHh05KWncb592h9ElklnPus++8kYn1Ic6acBR4LKFQ0RjjMVgwe5ypUpmTSUOjPA==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/nested-clients": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -651,22 +492,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.7.tgz", - "integrity": "sha512-PuJ1IkISG7ZDpBFYpGotaay6dYtmriBYuHJ/Oko4VHxh8YN5vfoWnMNYFEWuzOfyLmP7o9kDVW0BlYIpb3skvw==", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.6", - "@aws-sdk/credential-provider-http": "^3.972.8", - "@aws-sdk/credential-provider-ini": "^3.972.6", - "@aws-sdk/credential-provider-process": "^3.972.6", - "@aws-sdk/credential-provider-sso": "^3.972.6", - "@aws-sdk/credential-provider-web-identity": "^3.972.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -674,16 +514,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.6.tgz", - "integrity": "sha512-Yf34cjIZJHVnD92jnVYy3tNjM+Q4WJtffLK2Ehn0nKpZfqd1m7SI0ra22Lym4C53ED76oZENVSS2wimoXJtChQ==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -691,18 +530,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.6.tgz", - "integrity": "sha512-2+5UVwUYdD4BBOkLpKJ11MQ8wQeyJGDVMDRH5eWOULAh9d6HJq07R69M/mNNMC9NTjr3mB1T0KGDn4qyQh5jzg==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.988.0", - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/token-providers": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -710,75 +548,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.6.tgz", - "integrity": "sha512-pdJzwKtlDxBnvZ04pWMqttijmkUIlwOsS0GcxCjzEVyUMpARysl0S0ks74+gs2Pdev3Ujz+BTAjOc1tQgAxGqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/nested-clients": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.3.tgz", - "integrity": "sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.3.tgz", - "integrity": "sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.6.tgz", - "integrity": "sha512-g5DadWO58IgQKuq+uLL3pLohOwLiA67gB49xj8694BW+LpHLNu/tjCqwLfIaWvZyABbv0LXeNiiTuTnjdgkZWw==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@aws-crypto/crc32c": "5.2.0", - "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/crc64-nvme": "3.972.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -789,6 +568,7 @@ "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.3.tgz", "integrity": "sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -800,24 +580,11 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.3.tgz", - "integrity": "sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/middleware-logger": { "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.3.tgz", "integrity": "sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -832,6 +599,7 @@ "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.3.tgz", "integrity": "sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -845,38 +613,16 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.8.tgz", - "integrity": "sha512-/yJdahpN/q3Dc88qXBTQVZfnXryLnxfCoP4hGClbKjuF0VCMxrz3il7sj0GhIkEQt5OM5+lA88XrvbjjuwSxIg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-arn-parser": "^3.972.2", - "@smithy/core": "^3.23.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.3.tgz", - "integrity": "sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.74.tgz", + "integrity": "sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -887,6 +633,7 @@ "version": "3.972.8", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.8.tgz", "integrity": "sha512-3PGL+Kvh1PhB0EeJeqNqOWQgipdqFheO4OUKc6aYiFwEpM5t9AyE5hjjxZ5X6iSj8JiduWFZLPwASzF6wQRgFg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.973.8", @@ -902,48 +649,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.988.0.tgz", - "integrity": "sha512-OgYV9k1oBCQ6dOM+wWAMNNehXA8L4iwr7ydFV+JDHyuuu0Ko7tDXnLEtEmeQGYRcAFU3MGasmlBkMB8vf4POrg==", + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.8", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.988.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.6", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.23.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/middleware-retry": "^4.4.31", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.10", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.30", - "@smithy/util-defaults-mode-node": "^4.2.33", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -954,6 +671,7 @@ "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.3.tgz", "integrity": "sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -967,18 +685,16 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.988.0.tgz", - "integrity": "sha512-YyeSFNo0K2xN3fkBjcx0LzOz6oyt6PHXaiRgPg6vBM5YJ7N4UenlLZiMIDHrUfZpAOtpKX+xc81Z8N35kvO6/A==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1111.0.tgz", + "integrity": "sha512-ACp/VtDTw6AjFW5Q3M59uAMrBbAHeQS0UBIOIgUROO98Z3uhpiy37k9RmvxbXYVugmTcdNTy4DPVQtLG8sDy6g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-format-url": "^3.972.3", - "@smithy/middleware-endpoint": "^4.4.14", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -986,16 +702,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.988.0.tgz", - "integrity": "sha512-SXwhbe2v0Jno7QLIBmZWAL2eVzGmXkfLLy0WkM6ZJVhE0SFUcnymDwMUA1oMDUvyArzvKBiU8khQ2ImheCKOHQ==", + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "^3.972.8", - "@aws-sdk/types": "^3.973.1", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/types": "^3.974.4", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1003,17 +717,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.988.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.988.0.tgz", - "integrity": "sha512-xvXVlRVKHnF2h6fgWBm64aPP5J+58aJyGfRrQa/uFh8a9mcK68mLfJOYq+ZSxQy/UN3McafJ2ILAy7IWzT9kRw==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.8", - "@aws-sdk/nested-clients": "3.988.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1021,24 +734,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", - "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.972.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.2.tgz", - "integrity": "sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", "license": "Apache-2.0", "dependencies": { + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1049,6 +750,7 @@ "version": "3.988.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.988.0.tgz", "integrity": "sha512-HuXu4boeUWU0DQiLslbgdvuQ4ZMCo4Lsk97w8BIUokql2o9MvjE5dwqI5pzGt0K7afO1FybjidUQVTMLuZNTOA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -1061,25 +763,11 @@ "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/util-format-url": { - "version": "3.972.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.3.tgz", - "integrity": "sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/util-locate-window": { "version": "3.957.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.957.0.tgz", "integrity": "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1092,6 +780,7 @@ "version": "3.972.3", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.3.tgz", "integrity": "sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -1104,6 +793,7 @@ "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.6.tgz", "integrity": "sha512-966xH8TPqkqOXP7EwnEThcKKz0SNP9kVJBKd9M8bNXE4GSqVouMKKnFBwYnzbWVKuLXubzX5seokcX4a0JLJIA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.8", @@ -1125,14 +815,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.22", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz", - "integrity": "sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1143,6 +831,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -1176,7 +865,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -3043,8 +2731,7 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260501.1.tgz", "integrity": "sha512-B/VX2w3my/sCqxKyWOX7SxUpFC1uD8Gh7I2zbI1d3zA8p7Tx03AFsnuEx8lYLmcd8yONAA93YsAZb1wAaLK83w==", "dev": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -3075,8 +2762,7 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", @@ -3775,6 +3461,17 @@ "@floating-ui/utils": "^0.2.12" } }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", @@ -5391,7 +5088,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -5784,7 +5480,6 @@ "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", "integrity": "sha512-5Ar4OsZpJ54s21sy5oDNNW9gQtd4NuxCaiM7+JDTOU07D6VvlpLjYzAVCMB1+JzokN+08dAVomlx+b7bhJd3ww==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/client-runtime-utils": "7.7.0" }, @@ -6210,7 +5905,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.0", @@ -6576,50 +6272,26 @@ "license": "MIT" }, "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", - "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", - "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" } }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", - "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "node_modules/@smithy/abort-controller": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", + "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@smithy/util-base64": "^4.3.0", + "@smithy/types": "^4.12.0", "tslib": "^2.6.2" }, "engines": { @@ -6630,6 +6302,7 @@ "version": "4.4.6", "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz", "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.8", @@ -6644,20 +6317,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.0.tgz", - "integrity": "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg==", + "version": "3.33.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.2.tgz", + "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.12", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -6665,85 +6330,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", - "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz", - "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz", - "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz", - "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz", - "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.8", - "@smithy/types": "^4.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz", - "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -6751,30 +6344,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", - "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz", - "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz", + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", "license": "Apache-2.0", "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.0", - "@smithy/chunked-blob-reader-native": "^4.2.1", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -6785,6 +6361,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz", "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -6796,24 +6373,11 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz", - "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/invalid-dependency": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz", "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -6827,6 +6391,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6835,24 +6400,11 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz", - "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/middleware-content-length": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz", "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.8", @@ -6867,6 +6419,7 @@ "version": "4.4.14", "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.14.tgz", "integrity": "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.23.0", @@ -6886,6 +6439,7 @@ "version": "4.4.31", "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.31.tgz", "integrity": "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.8", @@ -6906,6 +6460,7 @@ "version": "4.2.9", "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/protocol-http": "^5.3.8", @@ -6920,6 +6475,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz", "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -6933,6 +6489,7 @@ "version": "4.3.8", "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.8", @@ -6945,15 +6502,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.10.tgz", - "integrity": "sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.2.tgz", + "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -6964,6 +6519,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -6977,6 +6533,7 @@ "version": "5.3.8", "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz", "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -6986,24 +6543,11 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", - "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/querystring-parser": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -7017,6 +6561,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz", "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0" @@ -7029,6 +6574,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz", "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -7039,18 +6585,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", - "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.2.tgz", + "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -7061,6 +6602,7 @@ "version": "4.11.3", "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.11.3.tgz", "integrity": "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.23.0", @@ -7076,9 +6618,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", - "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz", + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7091,6 +6633,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz", "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/querystring-parser": "^4.2.8", @@ -7105,6 +6648,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7119,6 +6663,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7131,6 +6676,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7143,6 +6689,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.0", @@ -7156,6 +6703,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7168,6 +6716,7 @@ "version": "4.3.30", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.30.tgz", "integrity": "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/property-provider": "^4.2.8", @@ -7183,6 +6732,7 @@ "version": "4.2.33", "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.33.tgz", "integrity": "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/config-resolver": "^4.4.6", @@ -7201,6 +6751,7 @@ "version": "3.2.8", "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz", "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.3.8", @@ -7215,6 +6766,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7227,6 +6779,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz", "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.12.0", @@ -7240,6 +6793,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz", "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/service-error-classification": "^4.2.8", @@ -7254,6 +6808,7 @@ "version": "4.5.12", "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.12.tgz", "integrity": "sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/fetch-http-handler": "^5.3.9", @@ -7269,22 +6824,11 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/util-utf8": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^4.2.0", @@ -7298,6 +6842,7 @@ "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz", "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.8", @@ -7312,6 +6857,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -7493,7 +7039,6 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -8473,13 +8018,15 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "license": "MIT", + "peer": true, "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" @@ -8489,7 +8036,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node": { "version": "20.11.0", @@ -8587,7 +8135,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8598,7 +8145,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -8651,7 +8197,6 @@ "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -9324,7 +8869,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9856,7 +9400,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10100,7 +9643,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -10370,7 +9912,8 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -11280,7 +10823,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11479,7 +11021,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12743,7 +12284,6 @@ "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -13598,7 +13138,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13933,6 +13472,7 @@ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", + "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -14164,6 +13704,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -14208,7 +13749,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mediaquery-text": { "version": "1.2.0", @@ -14505,7 +14047,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz", "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", @@ -14726,7 +14267,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", "integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -15004,7 +14544,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/own-keys": { "version": "1.0.1", @@ -15366,7 +14907,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -15642,7 +15182,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -15796,7 +15335,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -15863,7 +15401,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -15940,6 +15477,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-transform": "^1.0.0" } @@ -15949,6 +15487,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0" } @@ -15958,6 +15497,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -15969,6 +15509,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", @@ -15980,6 +15521,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", @@ -15992,6 +15534,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", @@ -16004,6 +15547,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" @@ -16014,6 +15558,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" @@ -16024,6 +15569,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", "license": "MIT", + "peer": true, "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", @@ -16035,6 +15581,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", "license": "MIT", + "peer": true, "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", @@ -16057,6 +15604,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.25.0" } @@ -16066,6 +15614,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", @@ -16089,6 +15638,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.4.tgz", "integrity": "sha512-CGr2BK5sLdZx+ARbeLO4HBZYa3qSG3FmwOVmzYs0Zp7n5SkrGqj+1CeNuubFNZEr64yMAQ20SanbFyIyHWZc8w==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", @@ -16102,6 +15652,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", "license": "MIT", + "peer": true, "dependencies": { "@remirror/core-constants": "3.0.0", "escape-string-regexp": "^4.0.0" @@ -16117,6 +15668,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.21.0" } @@ -16185,6 +15737,7 @@ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -16262,7 +15815,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16282,7 +15834,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16607,7 +16158,8 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/run-parallel": { "version": "1.2.0", @@ -16710,7 +16262,6 @@ "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17618,7 +17169,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17764,7 +17314,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -17888,7 +17437,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17990,7 +17538,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -18059,7 +17606,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/uint8array-extras": { "version": "1.5.0", @@ -18114,7 +17662,6 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -18378,7 +17925,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -18998,7 +18544,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -19041,7 +18586,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -19160,7 +18704,8 @@ "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/wait-on": { "version": "9.0.3", @@ -19343,7 +18888,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -19521,7 +19065,6 @@ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.85" }, @@ -19585,7 +19128,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, @@ -19652,7 +19194,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 46280e7b..c4f11ba2 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "dependencies": { "@auth/prisma-adapter": "^2.11.1", "@aws-sdk/client-s3": "^3.113.0", - "@aws-sdk/s3-request-presigner": "^3.952.0", + "@aws-sdk/s3-request-presigner": "^3.1111.0", "@formkit/auto-animate": "^0.7.0", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.7.0", diff --git a/prisma/migrations/20260817100000_data_export/migration.sql b/prisma/migrations/20260817100000_data_export/migration.sql new file mode 100644 index 00000000..1c461e38 --- /dev/null +++ b/prisma/migrations/20260817100000_data_export/migration.sql @@ -0,0 +1,21 @@ +-- CreateEnum +CREATE TYPE "DataExportStatus" AS ENUM ('PENDING', 'COMPLETED', 'FAILED'); + +-- CreateTable +CREATE TABLE "DataExport" ( + "id" TEXT NOT NULL, + "status" "DataExportStatus" NOT NULL DEFAULT 'PENDING', + "key" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "userId" TEXT NOT NULL, + + CONSTRAINT "DataExport_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DataExport_userId_idx" ON "DataExport"("userId"); + +-- AddForeignKey +ALTER TABLE "DataExport" ADD CONSTRAINT "DataExport_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3868b958..8a1ef199 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,12 @@ enum SubscriptionProvider { APPLE } +enum DataExportStatus { + PENDING + COMPLETED + FAILED +} + model User { id String @id @default(uuid(7)) createdAt DateTime @default(now()) @@ -46,6 +52,7 @@ model User { sessions Session[] projects ProjectMember[] transactions Transaction[] + dataExports DataExport[] } model Account { @@ -143,6 +150,22 @@ model ProjectAsset { @@index([projectId]) } +// One GDPR data-access request. The zip lives in R2 at `key` until `expiresAt` +// (7 days); a PENDING row younger than an hour blocks duplicate requests. +model DataExport { + id String @id @default(uuid(7)) + status DataExportStatus @default(PENDING) + key String? + createdAt DateTime @default(now()) + completedAt DateTime? + expiresAt DateTime? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + + @@index([userId]) +} + model ProjectMember { id Int @id @default(autoincrement()) role ProjectRole @default(VIEWER) diff --git a/src/app/api/users/export/route.ts b/src/app/api/users/export/route.ts new file mode 100644 index 00000000..b303e65e --- /dev/null +++ b/src/app/api/users/export/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, after } from "next/server"; + +import * as GdprExportService from "@src/server/service/gdpr-export-service"; +import { apiHandler, AuthApiContext } from "@src/lib/utils/api-handler"; +import { Success } from "@src/lib/utils/api-utils"; + +/** + * POST `/users/export` + * + * GDPR data-access request. Records the request and returns immediately; the + * zip is bundled in the background (`after`) and a signed download link, valid + * 7 days, is emailed to the user. 409 while a previous request is still + * building. + */ +async function requestDataExport(req: NextRequest, { user }: AuthApiContext) { + const exportId = await GdprExportService.beginDataExport(user.id); + after(() => GdprExportService.runDataExport(exportId, user.id)); + return Success({ requested: true }); +} + +export const POST = apiHandler(requestDataExport); diff --git a/src/lib/mail/mail.ts b/src/lib/mail/mail.ts index 6a9f308e..17c74007 100644 --- a/src/lib/mail/mail.ts +++ b/src/lib/mail/mail.ts @@ -21,6 +21,12 @@ export const sendProjectInviteEmail = async (email: string, projectTitle: string sendFormattedEmail(email, "Project Invitation", "Project Invitation", content, "Join project", link); }; +export const sendDataExportEmail = async (email: string, link: string) => { + const content = `Your personal data export is ready. Click the button below to download the archive containing your account information and project memberships. The link expires in 7 days.`; + + sendFormattedEmail(email, "Your data export", "Your data export is ready", content, "Download my data", link); +}; + export const sendMagicLinkEmail = async (email: string, token: string) => { const link = `${BASE_URL}/auth/magic-link?token=${token}`; const content = `Click the button below to sign in to your Scriptio account. This link will expire in 10 minutes and can only be used once. If you didn't request this, you can safely ignore this email.`; diff --git a/src/lib/s3.ts b/src/lib/s3.ts index 9e0f61b1..766811db 100644 --- a/src/lib/s3.ts +++ b/src/lib/s3.ts @@ -20,7 +20,10 @@ const client = new S3Client({ }, }); -export const getSignedDownloadUrl = async (name: string): Promise => { +/** SigV4 caps presigned-URL validity at 7 days. */ +export const MAX_SIGNED_URL_TTL_SECONDS = 7 * 24 * 3600; + +export const getSignedDownloadUrl = async (name: string, expiresIn = 900): Promise => { const params = { Bucket: env.S3_BUCKET, Key: name, @@ -28,7 +31,7 @@ export const getSignedDownloadUrl = async (name: string): Promise try { const command = new GetObjectCommand(params); - return await getSignedUrl(client, command, { expiresIn: 900 }); + return await getSignedUrl(client, command, { expiresIn }); } catch (e) { console.error("An error occurred while getting signed download URL from S3: ", e); return null; diff --git a/src/lib/utils/requests.ts b/src/lib/utils/requests.ts index 980f0dc1..e8c4dae6 100644 --- a/src/lib/utils/requests.ts +++ b/src/lib/utils/requests.ts @@ -125,6 +125,10 @@ export const deleteUser = () => { return request(`/api/users`, "DELETE"); }; +export const requestDataExport = () => { + return request(`/api/users/export`, "POST"); +}; + /* Auth */ export const requestMagicLink = (body: RequestMagicLinkBody) => { diff --git a/src/server/repository/data-export-repository.ts b/src/server/repository/data-export-repository.ts new file mode 100644 index 00000000..9bf95ac6 --- /dev/null +++ b/src/server/repository/data-export-repository.ts @@ -0,0 +1,38 @@ +import { DataExportStatus } from "../../generated/client/client"; +import prisma from "../db"; + +export class DataExportRepository { + createPending(userId: string) { + return prisma.dataExport.create({ data: { userId } }); + } + + /** The user's PENDING export created after `since`, if any (duplicate-request guard). */ + findActivePending(userId: string, since: Date) { + return prisma.dataExport.findFirst({ + where: { userId, status: DataExportStatus.PENDING, createdAt: { gte: since } }, + }); + } + + /** Mark PENDING rows older than `before` FAILED — leftovers of a crashed + * server that would otherwise block the user's next request forever. */ + failStalePending(userId: string, before: Date) { + return prisma.dataExport.updateMany({ + where: { userId, status: DataExportStatus.PENDING, createdAt: { lt: before } }, + data: { status: DataExportStatus.FAILED }, + }); + } + + markCompleted(id: string, key: string, expiresAt: Date) { + return prisma.dataExport.update({ + where: { id }, + data: { status: DataExportStatus.COMPLETED, key, completedAt: new Date(), expiresAt }, + }); + } + + markFailed(id: string) { + return prisma.dataExport.update({ + where: { id }, + data: { status: DataExportStatus.FAILED }, + }); + } +} diff --git a/src/server/repository/project-repository.ts b/src/server/repository/project-repository.ts index 56a8f727..cb6333b3 100644 --- a/src/server/repository/project-repository.ts +++ b/src/server/repository/project-repository.ts @@ -280,6 +280,28 @@ export class ProjectRepository { return prisma.projectInvitation.deleteMany({ where: { email } }); } + /** Memberships with raw project metadata — GDPR export (unlike + * fetchProjectMemberships, no poster URL signing or hydration). */ + listMembershipsWithProject(userId: string) { + return prisma.projectMember.findMany({ + where: { userId }, + select: { + role: true, + project: { + select: { + id: true, + title: true, + description: true, + author: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + orderBy: { project: { createdAt: "asc" } }, + }); + } + fetchProjectById(projectId: string) { return prisma.project.findUnique({ where: { id: projectId }, diff --git a/src/server/service/account-deletion-service.ts b/src/server/service/account-deletion-service.ts index 2c66146d..e8915a5c 100644 --- a/src/server/service/account-deletion-service.ts +++ b/src/server/service/account-deletion-service.ts @@ -23,6 +23,7 @@ import Stripe from "stripe"; import * as CollabUtils from "@src/lib/cloud/utils"; +import * as S3 from "@src/lib/s3"; import * as MagicLinkService from "@src/server/service/magic-link-service"; import * as ProjectService from "@src/server/service/project-service"; import * as UserService from "@src/server/service/user-service"; @@ -77,6 +78,8 @@ export async function deleteAccount(userId: string): Promise { MagicLinkService.deleteForEmail(user.email), UserService.deleteVerificationTokens(user.email), ProjectService.deleteInvitesByEmail(user.email), + // Any GDPR export zip still sitting in R2 (best-effort, logged inside). + S3.destroyPrefix(`gdpr-exports/${userId}/`), ]); await UserService.deleteUserFromId(userId); diff --git a/src/server/service/gdpr-export-service.ts b/src/server/service/gdpr-export-service.ts new file mode 100644 index 00000000..632c1321 --- /dev/null +++ b/src/server/service/gdpr-export-service.ts @@ -0,0 +1,105 @@ +/** + * GDPR data-access export. + * + * Bundles the personal data the database actually links to a user — account + * info/settings and project memberships — into a zip on R2 and mails them a + * signed download link, valid 7 days (the SigV4 maximum): + * + * user.json — account info + settings + * memberships.json — every project membership with its role + * + * Project content (uploaded assets, comments) is deliberately not attributed + * to users in the database — it belongs to the project — so there is nothing + * per-project to bundle. + * + * fflate's async `zip` compresses in a worker thread and the job runs after + * the response (`after`), so requests never block the event loop. + */ + +import { zip, strToU8, type Zippable } from "fflate"; + +import * as S3 from "@src/lib/s3"; +import * as ProjectService from "@src/server/service/project-service"; +import * as UserService from "@src/server/service/user-service"; +import { sendDataExportEmail } from "@src/lib/mail/mail"; +import { ConflictError } from "@src/lib/utils/api-utils"; +import { logger } from "@src/lib/utils/logger"; +import { DataExportRepository } from "../repository/data-export-repository"; + +const repository = new DataExportRepository(); + +const EXPORT_LINK_TTL_SECONDS = S3.MAX_SIGNED_URL_TTL_SECONDS; // 7 days +/** A PENDING row older than this is a crash leftover, not a running job. */ +const PENDING_STALE_MS = 60 * 60 * 1000; + +const zipAsync = (data: Zippable): Promise => + new Promise((resolve, reject) => { + zip(data, { level: 6 }, (err, out) => (err ? reject(err) : resolve(out))); + }); + +/** + * Validate and record a new export request. Throws ConflictError while a + * recent request is still building. Returns the export id for `runDataExport`. + */ +export async function beginDataExport(userId: string): Promise { + const staleBefore = new Date(Date.now() - PENDING_STALE_MS); + await repository.failStalePending(userId, staleBefore); + + const active = await repository.findActivePending(userId, staleBefore); + if (active) throw new ConflictError("A data export is already being prepared"); + + const row = await repository.createPending(userId); + return row.id; +} + +/** + * Build the zip, upload it and email the link. Runs in the background after + * the request already returned — never throws, records FAILED instead. + */ +export async function runDataExport(exportId: string, userId: string): Promise { + try { + const user = await UserService.getUserFromId(userId); + if (!user) throw new Error("User no longer exists"); + const memberships = await ProjectService.getMembershipsWithProject(userId); + + // Only the newest export link should stay valid; this also reclaims the + // previous zip instead of waiting out its 7 days. + await S3.destroyPrefix(`gdpr-exports/${userId}/`); + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const key = `gdpr-exports/${userId}/scriptio-data-export-${timestamp}.zip`; + + const archive = await zipAsync({ + "user.json": strToU8(JSON.stringify(user, null, 2)), + "memberships.json": strToU8( + JSON.stringify( + memberships.map((m) => ({ + projectId: m.project.id, + title: m.project.title, + description: m.project.description, + author: m.project.author, + role: m.role, + projectCreatedAt: m.project.createdAt, + projectUpdatedAt: m.project.updatedAt, + })), + null, + 2, + ), + ), + }); + + const uploaded = await S3.putObject(key, archive, "application/zip"); + if (!uploaded) throw new Error("Failed to upload the export zip"); + + const url = await S3.getSignedDownloadUrl(key, EXPORT_LINK_TTL_SECONDS); + if (!url) throw new Error("Failed to sign the export download URL"); + + const expiresAt = new Date(Date.now() + EXPORT_LINK_TTL_SECONDS * 1000); + await repository.markCompleted(exportId, key, expiresAt); + await sendDataExportEmail(user.email, url); + logger.info("[DataExport] Export completed", { userId, exportId, key }); + } catch (e) { + logger.error("[DataExport] Export failed", { userId, exportId, error: e }); + await repository.markFailed(exportId).catch(() => {}); + } +} diff --git a/src/server/service/project-service.ts b/src/server/service/project-service.ts index 0352632c..419e7d7e 100644 --- a/src/server/service/project-service.ts +++ b/src/server/service/project-service.ts @@ -81,6 +81,10 @@ export async function getMembershipsForTeardown(userId: string) { return repository.listMembershipsForTeardown(userId); } +export async function getMembershipsWithProject(userId: string) { + return repository.listMembershipsWithProject(userId); +} + export async function deleteInvitesByEmail(email: string) { return repository.deleteInvitesByEmail(email); } From e2ca9f0334fdef4039505219a1529c26ecebe5c0 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Mon, 17 Aug 2026 19:42:16 +0200 Subject: [PATCH 4/6] fixed blur filter on landing page buttons, fixed revised empty lines --- landing/components/home/Landing.module.css | 27 ++- .../navbar/LandingPageNavbar.module.css | 1 - landing/package-lock.json | 3 - .../extensions/node-id-dedup-extension.ts | 69 +++++- .../extensions/revisions-extension.ts | 19 +- .../repro/revisions-empty-line-delete.test.ts | 208 ++++++++++++++++++ 6 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 src/tests/repro/revisions-empty-line-delete.test.ts diff --git a/landing/components/home/Landing.module.css b/landing/components/home/Landing.module.css index cead9533..65fe6573 100644 --- a/landing/components/home/Landing.module.css +++ b/landing/components/home/Landing.module.css @@ -258,7 +258,6 @@ border-radius: 14px; background-color: rgba(255, 255, 255, 0.06); backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); color: var(--primary-text); text-decoration: none; cursor: pointer; @@ -285,23 +284,21 @@ left: 50%; min-width: 100%; padding-top: 0.6rem; /* keeps hover alive across the gap */ - transform: translate(-50%, -6px); - opacity: 0; - visibility: hidden; + transform: translateX(-50%); pointer-events: none; - transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94); z-index: 3; } .ctaMenu:hover .ctaDropdown, .ctaMenu:focus-within .ctaDropdown, .ctaMenuOpen .ctaDropdown { - transform: translate(-50%, 0); - opacity: 1; - visibility: visible; pointer-events: auto; } +/* The opacity/transform transition lives on the SAME element as backdrop-filter + (not a parent) — Chrome/WebKit only keep re-sampling a blur while its own + element is what's animating; on a static child under an animating ancestor + the blur freezes as a cached bitmap until the transition settles. */ .ctaDropdownPanel { display: flex; flex-direction: column; @@ -311,8 +308,18 @@ border-radius: 12px; background-color: rgba(255, 255, 255, 0.06); backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + opacity: 0; + transform: translateY(-6px); + will-change: opacity, transform, backdrop-filter; + transition: opacity 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94), transform 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.ctaMenu:hover .ctaDropdownPanel, +.ctaMenu:focus-within .ctaDropdownPanel, +.ctaMenuOpen .ctaDropdownPanel { + opacity: 1; + transform: translateY(0); } .ctaDropdownItem { @@ -549,7 +556,6 @@ transition: box-shadow 0.3s ease; background-color: rgba(255, 255, 255, 0.04); backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); } .glassCard::before { @@ -698,7 +704,6 @@ border-radius: 1rem; background-color: rgba(255, 255, 255, 0.04); backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); box-shadow: var(--shadow-s); overflow: hidden; } diff --git a/landing/components/navbar/LandingPageNavbar.module.css b/landing/components/navbar/LandingPageNavbar.module.css index 277105d4..bedf1c06 100644 --- a/landing/components/navbar/LandingPageNavbar.module.css +++ b/landing/components/navbar/LandingPageNavbar.module.css @@ -17,7 +17,6 @@ .navbarScrolled { background-color: color-mix(in srgb, var(--secondary) 75%, transparent); backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); border-bottom-color: var(--separator); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); } diff --git a/landing/package-lock.json b/landing/package-lock.json index 43e71e78..7c85d2e1 100644 --- a/landing/package-lock.json +++ b/landing/package-lock.json @@ -660,7 +660,6 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -850,7 +849,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -860,7 +858,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, diff --git a/src/lib/screenplay/extensions/node-id-dedup-extension.ts b/src/lib/screenplay/extensions/node-id-dedup-extension.ts index 1dbc74f2..296ca130 100644 --- a/src/lib/screenplay/extensions/node-id-dedup-extension.ts +++ b/src/lib/screenplay/extensions/node-id-dedup-extension.ts @@ -1,4 +1,5 @@ import { Extension } from "@tiptap/core"; +import { Node as PMNode } from "@tiptap/pm/model"; import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { ReplaceAroundStep, ReplaceStep } from "@tiptap/pm/transform"; import { generateNodeId } from "@src/lib/screenplay/nodes"; @@ -60,23 +61,73 @@ export const createNodeIdDedupExtension = (config: NodeIdDedupConfig) => { const tr = newState.tr; let modified = false; - const seenDataIds = new Set(); + /** Position of the node currently holding each data-id. A bare + * number, not the node: the duplicate-free pass is the one that + * runs on every Enter, and it must not allocate per line. */ + const seen = new Map(); + /** Ids whose holder is an EMPTY node — the only case in which the + * earlier node has to be looked up again (see below). Screenplay + * lines mostly carry text, so this stays small. */ + const emptyHolders = new Set(); + + /** Give the node at `pos` a fresh id, so the other keeps `dataId`. */ + const reid = (pos: number, node: PMNode, dataId: string) => { + const newId = generateNodeId(); + tr.setNodeMarkup(pos, undefined, { ...node.attrs, "data-id": newId }); + modified = true; + + if (hasPaste && node.type.name === ScreenplayElement.Scene) { + config.duplicatePersistentScene(dataId, newId); + } + }; newState.doc.forEach((node, pos) => { const dataId: string | null = node.attrs["data-id"] ?? null; if (dataId === null) return; - if (seenDataIds.has(dataId)) { - const newId = generateNodeId(); - tr.setNodeMarkup(pos, undefined, { ...node.attrs, "data-id": newId }); - modified = true; + const firstPos = seen.get(dataId); + if (firstPos === undefined) { + seen.set(dataId, pos); + if (node.content.size === 0) emptyHolders.add(dataId); + return; + } - if (hasPaste && node.type.name === ScreenplayElement.Scene) { - config.duplicatePersistentScene(dataId, newId); + // Which of the two is the new node? An Enter that splits a + // line copies the whole attribute set onto both halves, so + // the id cannot say — but the text can: a line's identity + // follows its words, so the half the split left EMPTY is the + // new one. That is the rule scene headings already apply for + // themselves (see scene-node's Enter handler); this extends + // it to every element type. + // + // It matters beyond tidiness, because everything keyed on + // data-id follows the id rather than the text. Pressing Enter + // at the START of a line — inserting a blank line above it — + // leaves the blank half holding the id, so the revision + // baseline reads the line below as one it has never seen and + // colours the whole of it, though not a word of it changed. + // + // Copies carry the same content on both sides, so a paste or + // drop never reaches the asymmetry and its later node is + // re-identified exactly as before. + // + // The earlier node is fetched only on the split-at-start + // branch, which needs its attributes to rewrite them. `nodeAt` + // is a linear scan of the top level, and a paste can bring + // hundreds of duplicates through here at once — but a copy is + // never the empty half of a split, so the emptiness test + // (already answered, no lookup needed) keeps every one of them + // on the branch below, where the node is in hand already. + if (node.content.size > 0 && emptyHolders.has(dataId)) { + const first = newState.doc.nodeAt(firstPos); + if (first) { + seen.set(dataId, pos); + emptyHolders.delete(dataId); + reid(firstPos, first, dataId); + return; } - } else { - seenDataIds.add(dataId); } + reid(pos, node, dataId); }); return modified ? tr.setMeta("nodeDedupId", true) : null; diff --git a/src/lib/screenplay/extensions/revisions-extension.ts b/src/lib/screenplay/extensions/revisions-extension.ts index dc7aa176..30446c32 100644 --- a/src/lib/screenplay/extensions/revisions-extension.ts +++ b/src/lib/screenplay/extensions/revisions-extension.ts @@ -1,6 +1,7 @@ import { Editor, Extension, Mark, mergeAttributes } from "@tiptap/core"; import { Node as PMNode } from "@tiptap/pm/model"; import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Mapping } from "@tiptap/pm/transform"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; import { ySyncPluginKey } from "@tiptap/y-tiptap"; @@ -282,13 +283,23 @@ const goneIds = (tr: Transaction, oldDoc: PMNode, newDoc: PMNode, lo: number, hi // the join landed in and misses the line that was taken out, which is the only // one being looked for. Each step's own map reports what it removed in its // INPUT coordinates, so rebase those onto the original doc. + // + // The rebase is built from a COPY of the preceding maps rather than from + // `tr.mapping.slice(0, i)`: a sliced Mapping only honours its bounds in `map`, + // while `invert()` walks the whole underlying array (it delegates to + // `appendMappingInverted`, which ignores `from`/`to`). So the sliced-and- + // inverted mapping ran this step's own inverse too, and at i = 0 — a plain + // one-step Backspace — `back` was that inverse instead of the identity. Every + // reported span then came out shifted by the size of the cut, sweeping the + // untouched line just past it into `before` and reporting it as removed. + const maps = tr.mapping.maps; let oLo = Infinity; let oHi = -Infinity; - tr.mapping.maps.forEach((map, i) => { - const back = tr.mapping.slice(0, i).invert(); + maps.forEach((map, i) => { + const back = i === 0 ? null : new Mapping(maps.slice(0, i)).invert(); map.forEach((os: number, oe: number) => { - const a = back.map(os, -1); - const b = back.map(oe, 1); + const a = back ? back.map(os, -1) : os; + const b = back ? back.map(oe, 1) : oe; if (a < oLo) oLo = a; if (b > oHi) oHi = b; }); diff --git a/src/tests/repro/revisions-empty-line-delete.test.ts b/src/tests/repro/revisions-empty-line-delete.test.ts new file mode 100644 index 00000000..855b5849 --- /dev/null +++ b/src/tests/repro/revisions-empty-line-delete.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; + +import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; +import { createNodeIdDedupExtension } from "@src/lib/screenplay/extensions/node-id-dedup-extension"; +import { createRevisionsExtension } from "@src/lib/screenplay/extensions/revisions-extension"; +import { RevisionBaseEntry, captureRevisionBaseline } from "@src/lib/screenplay/revisions"; + +/** + * Adding a blank line in revision mode and taking it straight back out must + * leave no trace — least of all on the line the cursor falls back onto, which + * the user never touched. + * + * Two independent faults each produced that phantom asterisk, and both are + * guarded here because either one alone reproduces it: + * + * 1. `goneIds` rebased each step's removed span onto the original document + * through `tr.mapping.slice(0, i).invert()`. A sliced Mapping honours its + * bounds when mapping but NOT when inverting (`invert` delegates to + * `appendMappingInverted`, which walks the whole array), so at i = 0 — a + * one-step Backspace — the "identity" ran the cut's own inverse. The span + * came out shifted past the cut, sweeping the untouched line beyond it into + * the removed set; that line was in the baseline, so the flush concluded a + * baseline line had been deleted and anchored the cut on a neighbour. + * + * 2. Enter copies a line's attributes onto both halves of the split, and the + * dedup pass re-identified the later of the two. Splitting at the START of a + * line — inserting a blank line above it — therefore left the id on the blank + * half and handed the text a new one, so the baseline read the line below as + * new and coloured all of it; deleting the blank line then looked like the + * removal of a baseline line, phantom anchor and all. + * + * The editor here mirrors the app's: the dedup extension is what decides which + * half of a split keeps its identity, so leaving it out would hide fault 2 (and + * mask fault 1 behind duplicate ids that cancel in the comparison). + */ + +type RevState = { + enabled: boolean; + current: number; + display: "all" | "hidden" | "current"; + /** Revision the baseline belongs to; null → no baseline, event-based path. */ + baseIndex: number | null; +}; + +function makeEditor(lines: string[]) { + const el = document.createElement("div"); + document.body.appendChild(el); + const rev: RevState = { enabled: false, current: 0, display: "all", baseIndex: null }; + const base = new Map(); + + const editor = new Editor({ + element: el, + injectCSS: false, + autofocus: false, + content: { + type: "doc", + content: lines.map((text, i) => ({ + type: "action", + attrs: { "data-id": `n${i}`, class: "action" }, + content: text ? [{ type: "text", text }] : [], + })), + }, + extensions: [ + ...BASE_EXTENSIONS, + createNodeIdDedupExtension({ duplicatePersistentScene: () => {} }), + createRevisionsExtension({ + getRevisionsEnabled: () => rev.enabled, + getCurrentRevision: () => rev.current, + getDisplayMode: () => rev.display, + getBaseline: () => + rev.baseIndex === null ? null : { index: rev.baseIndex, get: (id: string) => base.get(id) }, + }), + ], + }); + + /** Snapshot the document as the baseline for `index`, as opening it would. */ + const capture = (index: number) => { + base.clear(); + captureRevisionBaseline(editor.state.doc, index).forEach((v, k) => base.set(k, v)); + rev.baseIndex = index; + }; + + return { editor, rev, capture }; +} + +/** Stamping is debounced (FLUSH_DELAY = 220ms); wait past it before asserting. */ +const settle = () => new Promise((r) => setTimeout(r, 320)); + +/** Document position just inside top-level child `index`. */ +function insidePos(editor: Editor, index: number): number { + let pos = -1; + editor.state.doc.forEach((node, p, i) => { + if (i === index) pos = p + 1; + }); + return pos; +} + +/** Does child `index` carry any revision signal at all (mark or node attribute)? */ +const isMarked = (editor: Editor, index: number): boolean => { + const node = editor.state.doc.child(index); + if (node.attrs.revision != null) return true; + let marked = false; + node.descendants((child) => { + if (child.isText && child.marks.some((m) => m.type.name === "revision")) marked = true; + return !marked; + }); + return marked; +}; + +/** Indices of every top-level line carrying a revision signal. */ +const markedLines = (editor: Editor): number[] => { + const out: number[] = []; + editor.state.doc.forEach((_node, _pos, i) => { + if (isMarked(editor, i)) out.push(i); + }); + return out; +}; + +const textOf = (editor: Editor, index: number): string => editor.state.doc.child(index).textContent; + +/** A real Backspace, through the keymap the user's key actually reaches. */ +const backspaceAt = (editor: Editor, index: number) => { + editor.chain().focus().setTextSelection(insidePos(editor, index)).run(); + editor.commands.keyboardShortcut("Backspace"); +}; + +const enterAt = (editor: Editor, index: number, offset: number) => { + editor + .chain() + .focus() + .setTextSelection(insidePos(editor, index) + offset) + .splitBlock() + .run(); +}; + +const LINES = ["FADE IN ON A HOUSE", "A man walks in", "He sits down", "SILENCE"]; + +describe("revisions: deleting a blank line added in this revision", () => { + it("leaves the line the cursor lands on unmarked (Enter at the end of a line)", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + enterAt(editor, 1, LINES[1].length); + await settle(); + expect(editor.state.doc.childCount).toBe(LINES.length + 1); + // The blank line is this revision's; nothing else is. + expect(markedLines(editor)).toEqual([2]); + + backspaceAt(editor, 2); + await settle(); + + expect(editor.state.doc.childCount).toBe(LINES.length); + expect(textOf(editor, 1)).toBe(LINES[1]); + // The cursor fell back onto line 1, which the user never edited. + expect(markedLines(editor)).toEqual([]); + }); + + it("leaves the line the cursor lands on unmarked (Enter at the start of a line)", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + // A blank line inserted ABOVE line 2 — the split that hands the new node + // the earlier position, so identity has to stay with the text below it. + enterAt(editor, 2, 0); + await settle(); + expect(editor.state.doc.childCount).toBe(LINES.length + 1); + expect(textOf(editor, 3)).toBe(LINES[2]); + // Only the blank line is new — the line it was inserted above is the same + // line it always was, not a word of it changed. + expect(markedLines(editor)).toEqual([2]); + + backspaceAt(editor, 2); + await settle(); + + expect(editor.state.doc.childCount).toBe(LINES.length); + expect(textOf(editor, 1)).toBe(LINES[1]); + expect(markedLines(editor)).toEqual([]); + }); + + it("still anchors the cut when the deleted blank line existed at the baseline", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Wipe line 2's words, then take the emptied line itself out. Unlike the + // cases above this is a real cut of a line the last issued pages carried, + // so its asterisk has to survive on a neighbour — the fix must not have + // bought its silence by dropping genuine deletions. + const start = insidePos(editor, 2); + editor + .chain() + .focus() + .deleteRange({ from: start, to: start + LINES[2].length }) + .run(); + await settle(); + backspaceAt(editor, 2); + await settle(); + + expect(editor.state.doc.childCount).toBe(LINES.length - 1); + expect(markedLines(editor)).toEqual([1]); + }); +}); From b82139f03d92aeac6d06b3d55bb4074e735a5c2e Mon Sep 17 00:00:00 2001 From: Lycoon Date: Mon, 17 Aug 2026 22:04:56 +0200 Subject: [PATCH 5/6] added continued labels toggles to layout settings, improved layout settings hierarchy visibility --- .../dashboard/project/ExportProject.tsx | 4 ++ .../project/LayoutSettings.module.css | 24 +++++++- .../dashboard/project/LayoutSettings.tsx | 58 ++++++++++++++++++- components/editor/DocumentEditorPanel.tsx | 11 ++++ messages/de.json | 4 ++ messages/en.json | 4 ++ messages/es.json | 4 ++ messages/fr.json | 4 ++ messages/ja.json | 4 ++ messages/ko.json | 4 ++ messages/pl.json | 4 ++ messages/zh.json | 4 ++ src/context/ProjectContext.tsx | 46 +++++++++++++++ src/lib/adapters/pdf/pdf-adapter.ts | 9 ++- src/lib/adapters/pdf/pdf.worker.ts | 8 ++- src/lib/editor/use-document-editor.ts | 4 ++ src/lib/project/project-doc.ts | 4 ++ src/lib/project/project-repository.ts | 8 +++ .../extensions/pagination-extension.ts | 21 ++++++- styles/scriptio.css | 7 +++ 20 files changed, 228 insertions(+), 8 deletions(-) diff --git a/components/dashboard/project/ExportProject.tsx b/components/dashboard/project/ExportProject.tsx index 78c2950e..78b07f14 100644 --- a/components/dashboard/project/ExportProject.tsx +++ b/components/dashboard/project/ExportProject.tsx @@ -63,6 +63,8 @@ const ExportProject = () => { sceneNumberOnRight, contdLabel, moreLabel, + showContdDialogue, + showContdPageBreak, } = useContext(ProjectContext); const ydoc = repository?.getState(); const userContext = useContext(UserContext); @@ -192,6 +194,8 @@ const ExportProject = () => { sceneNumberOnRight, contdLabel, moreLabel, + showContdDialogue, + showContdPageBreak, editorElement: editor?.view?.dom, // Title page is omitted entirely when its toggle is off. titlePageElement: includeTitlePage ? titlePageEditor?.view?.dom : undefined, diff --git a/components/dashboard/project/LayoutSettings.module.css b/components/dashboard/project/LayoutSettings.module.css index 036d06b3..407c7ddd 100644 --- a/components/dashboard/project/LayoutSettings.module.css +++ b/components/dashboard/project/LayoutSettings.module.css @@ -17,12 +17,16 @@ transform: rotate(90deg); } -/* Body of a collapsible section, revealed when the header is expanded. */ +/* Body of a collapsible section, revealed when the header is expanded. The + indent and left rule tie the content to the heading above it, so an open + section reads as one block instead of running into the next heading. */ .sectionBody { display: flex; flex-direction: column; gap: 12px; - margin-top: 12px; + margin-top: 10px; + padding-left: 14px; + border-left: 2px solid var(--separator); } .input { @@ -79,6 +83,16 @@ border-color: var(--primary); } +/* A label input whose continuation case is switched off: still readable, but + visibly inert (the input itself is `disabled`). */ +.labelDisabled { + opacity: 0.5; +} + +.labelDisabled .input { + cursor: not-allowed; +} + .marginsSection { display: flex; flex-direction: column; @@ -262,6 +276,12 @@ * narrow dashboard drawer that overflows off-screen, so let the label + inputs * wrap and stack the header/footer inputs vertically instead. */ @media (max-width: 767px) { + /* Narrower indent in the drawer — the rule still marks the section, but + every inner row keeps its width. */ + .sectionBody { + padding-left: 10px; + } + .marginRow { flex-wrap: wrap; gap: 8px; diff --git a/components/dashboard/project/LayoutSettings.tsx b/components/dashboard/project/LayoutSettings.tsx index ff995fc8..4e08e1e6 100644 --- a/components/dashboard/project/LayoutSettings.tsx +++ b/components/dashboard/project/LayoutSettings.tsx @@ -105,6 +105,10 @@ const LayoutSettings = () => { setContdLabel, moreLabel, setMoreLabel, + showContdDialogue, + setShowContdDialogue, + showContdPageBreak, + setShowContdPageBreak, headerLeft, setHeaderLeft, headerMiddle, @@ -139,6 +143,8 @@ const LayoutSettings = () => { const [localHeadingSpacing, setLocalHeadingSpacing] = useState(sceneHeadingSpacing); const [localContdLabel, setLocalContdLabel] = useState(() => stripParens(contdLabel)); const [localMoreLabel, setLocalMoreLabel] = useState(() => stripParens(moreLabel)); + const [localShowContdDialogue, setLocalShowContdDialogue] = useState(showContdDialogue); + const [localShowContdPageBreak, setLocalShowContdPageBreak] = useState(showContdPageBreak); const [localHeaderLeft, setLocalHeaderLeft] = useState(headerLeft); const [localHeaderMiddle, setLocalHeaderMiddle] = useState(headerMiddle); const [localHeaderRight, setLocalHeaderRight] = useState(headerRight); @@ -181,6 +187,8 @@ const LayoutSettings = () => { setLocalHeadingSpacing(sceneHeadingSpacing); setLocalContdLabel(stripParens(contdLabel)); setLocalMoreLabel(stripParens(moreLabel)); + setLocalShowContdDialogue(showContdDialogue); + setLocalShowContdPageBreak(showContdPageBreak); setLocalHeaderLeft(headerLeft); setLocalHeaderMiddle(headerMiddle); setLocalHeaderRight(headerRight); @@ -201,6 +209,8 @@ const LayoutSettings = () => { sceneHeadingSpacing, contdLabel, moreLabel, + showContdDialogue, + showContdPageBreak, headerLeft, headerMiddle, headerRight, @@ -222,6 +232,8 @@ const LayoutSettings = () => { localHeadingSpacing !== sceneHeadingSpacing || `(${localContdLabel})` !== contdLabel || `(${localMoreLabel})` !== moreLabel || + localShowContdDialogue !== showContdDialogue || + localShowContdPageBreak !== showContdPageBreak || localHeaderLeft !== headerLeft || localHeaderMiddle !== headerMiddle || localHeaderRight !== headerRight || @@ -248,6 +260,10 @@ const LayoutSettings = () => { contdLabel, localMoreLabel, moreLabel, + localShowContdDialogue, + showContdDialogue, + localShowContdPageBreak, + showContdPageBreak, localHeaderLeft, headerLeft, localHeaderMiddle, @@ -278,6 +294,8 @@ const LayoutSettings = () => { setLocalHeadingSpacing(1); setLocalContdLabel("CONT'D"); setLocalMoreLabel("MORE"); + setLocalShowContdDialogue(true); + setLocalShowContdPageBreak(true); setLocalHeaderLeft(""); setLocalHeaderMiddle(""); setLocalHeaderRight("#."); @@ -306,6 +324,8 @@ const LayoutSettings = () => { setSceneHeadingSpacing(localHeadingSpacing); setContdLabel(`(${localContdLabel})`); setMoreLabel(`(${localMoreLabel})`); + setShowContdDialogue(localShowContdDialogue); + setShowContdPageBreak(localShowContdPageBreak); setHeaderLeft(localHeaderLeft); setHeaderMiddle(localHeaderMiddle); setHeaderRight(localHeaderRight); @@ -530,6 +550,10 @@ const LayoutSettings = () => { ); }; + // The CONT'D label serves both continuation cases, so it stays editable as + // long as either one of them still renders it. + const contdLabelUsed = localShowContdDialogue || localShowContdPageBreak; + const pageFormatOptions: DropdownOption[] = [ { value: "LETTER", label: 'US Letter (8.5" x 11")' }, { value: "A4", label: "A4 (210mm x 297mm)" }, @@ -678,8 +702,36 @@ const LayoutSettings = () => {
+
setLocalShowContdDialogue(!localShowContdDialogue)} + > +
+ {localShowContdDialogue &&
} +
+
+ {t("contdOnDialogue")} + {t("contdOnDialogueDesc")} +
+
+ +
setLocalShowContdPageBreak(!localShowContdPageBreak)} + > +
+ {localShowContdPageBreak &&
} +
+
+ {t("contdOnPageBreak")} + {t("contdOnPageBreakDesc")} +
+
+ + {/* The labels themselves are only reachable through the toggles + above, so each input dims once nothing renders it. */}
-
+
{t("contdTitle")}
{ onChange={(e) => setLocalContdLabel(e.target.value)} className={`${sharedStyles.input} ${styles.input}`} placeholder="CONT'D" + disabled={!contdLabelUsed} />
-
+
{t("moreTitle")}
{ onChange={(e) => setLocalMoreLabel(e.target.value)} className={`${sharedStyles.input} ${styles.input}`} placeholder="MORE" + disabled={!localShowContdPageBreak} />
diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index 9516e61a..e881fff5 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -99,6 +99,8 @@ const DocumentEditorPanel = ({ sceneNumberOnRight, contdLabel, moreLabel, + showContdDialogue, + showContdPageBreak, headerLeft, headerMiddle, headerRight, @@ -449,6 +451,12 @@ const DocumentEditorPanel = ({ editorElement.classList.remove("production-locked"); } + if (showContdDialogue) { + editorElement.classList.remove("hide-contd-dialogue"); + } else { + editorElement.classList.add("hide-contd-dialogue"); + } + editorElement.style.setProperty("--contd-label", `"${contdLabel}"`); editorElement.style.setProperty("--more-label", `"${moreLabel}"`); @@ -491,6 +499,7 @@ const DocumentEditorPanel = ({ editor .chain() .updateStartNewPageTypes(startNewPageTypes) + .updateShowContdPageBreak(showContdPageBreak) .updatePageSize(pageSize) .updateMargins({ top: pageMargins.top * 96, @@ -533,6 +542,8 @@ const DocumentEditorPanel = ({ sceneNumberOnRight, contdLabel, moreLabel, + showContdDialogue, + showContdPageBreak, headerLeft, headerMiddle, headerRight, diff --git a/messages/de.json b/messages/de.json index 6d6383e1..3b3152f0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -499,6 +499,10 @@ "continuedLabels": "Fortsetzungsbezeichnungen", "moreTitle": "(MORE) Bezeichnung", "contdTitle": "(CONT'D) Bezeichnung", + "contdOnDialogue": "Fortgesetzter Dialog", + "contdOnDialogueDesc": "Wenn eine Figur nach einer Unterbrechung weiterspricht", + "contdOnPageBreak": "Seitenumbrüche", + "contdOnPageBreakDesc": "Wenn ein Dialog auf der nächsten Seite weitergeht", "pageHeader": "Seitenkopf", "headerLeft": "Links", "headerMiddle": "Mitte", diff --git a/messages/en.json b/messages/en.json index 7ad1fa82..03428e2f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -498,6 +498,10 @@ "continuedLabels": "Continued labels", "moreTitle": "(MORE) Label", "contdTitle": "(CONT'D) Label", + "contdOnDialogue": "Resumed dialogue", + "contdOnDialogueDesc": "When a character speaks again after an interruption", + "contdOnPageBreak": "Page breaks", + "contdOnPageBreakDesc": "When dialogue continues onto the next page", "pageHeader": "Page header", "headerLeft": "Left", "headerMiddle": "Middle", diff --git a/messages/es.json b/messages/es.json index 5654b9e9..b1c594c1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -498,6 +498,10 @@ "continuedLabels": "Etiquetas de continuación", "moreTitle": "(MORE) Etiqueta", "contdTitle": "(CONT'D) Etiqueta", + "contdOnDialogue": "Diálogo reanudado", + "contdOnDialogueDesc": "Cuando un personaje vuelve a hablar tras una interrupción", + "contdOnPageBreak": "Saltos de página", + "contdOnPageBreakDesc": "Cuando el diálogo continúa en la página siguiente", "pageHeader": "Encabezado de página", "headerLeft": "Izquierda", "headerMiddle": "Centro", diff --git a/messages/fr.json b/messages/fr.json index 18f90168..e8b5c9ef 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -499,6 +499,10 @@ "continuedLabels": "Étiquettes de continuité", "moreTitle": "(MORE) Étiquette", "contdTitle": "(CONT'D) Étiquette", + "contdOnDialogue": "Dialogue repris", + "contdOnDialogueDesc": "Quand un personnage reprend la parole après une interruption", + "contdOnPageBreak": "Sauts de page", + "contdOnPageBreakDesc": "Quand un dialogue se poursuit sur la page suivante", "pageHeader": "En-tête de page", "headerLeft": "Gauche", "headerMiddle": "Centre", diff --git a/messages/ja.json b/messages/ja.json index 85632cd9..b8f11e31 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -498,6 +498,10 @@ "continuedLabels": "継続ラベル", "moreTitle": "(MORE) ラベル", "contdTitle": "(CONT'D) ラベル", + "contdOnDialogue": "再開したセリフ", + "contdOnDialogueDesc": "中断後に同じ人物が再び話すとき", + "contdOnPageBreak": "改ページ", + "contdOnPageBreakDesc": "セリフが次のページに続くとき", "pageHeader": "ページヘッダー", "headerLeft": "左", "headerMiddle": "中央", diff --git a/messages/ko.json b/messages/ko.json index 57cd26ee..764637ce 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -498,6 +498,10 @@ "continuedLabels": "연속 라벨", "moreTitle": "(MORE) 라벨", "contdTitle": "(CONT'D) 라벨", + "contdOnDialogue": "이어지는 대사", + "contdOnDialogueDesc": "중단 후 같은 인물이 다시 말할 때", + "contdOnPageBreak": "페이지 나눔", + "contdOnPageBreakDesc": "대사가 다음 페이지로 이어질 때", "pageHeader": "페이지 머리글", "headerLeft": "왼쪽", "headerMiddle": "가운데", diff --git a/messages/pl.json b/messages/pl.json index fe932053..8acc588f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -498,6 +498,10 @@ "continuedLabels": "Etykiety kontynuacji", "moreTitle": "(MORE) Etykieta", "contdTitle": "(CONT'D) Etykieta", + "contdOnDialogue": "Wznowiony dialog", + "contdOnDialogueDesc": "Gdy postać znów zabiera głos po przerwaniu", + "contdOnPageBreak": "Podziały stron", + "contdOnPageBreakDesc": "Gdy dialog jest kontynuowany na następnej stronie", "pageHeader": "Nagłówek strony", "headerLeft": "Lewa", "headerMiddle": "Środek", diff --git a/messages/zh.json b/messages/zh.json index 74e2c228..170de38a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -498,6 +498,10 @@ "continuedLabels": "续页标签", "moreTitle": "(MORE) 标签", "contdTitle": "(CONT'D) 标签", + "contdOnDialogue": "续说对白", + "contdOnDialogueDesc": "角色被打断后再次说话时", + "contdOnPageBreak": "分页", + "contdOnPageBreakDesc": "对白延续到下一页时", "pageHeader": "页眉", "headerLeft": "左", "headerMiddle": "中", diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 1a76d169..0bd23b63 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -111,6 +111,10 @@ export interface ProjectContextType { setContdLabel: (label: string) => void; moreLabel: string; setMoreLabel: (label: string) => void; + showContdDialogue: boolean; + setShowContdDialogue: (show: boolean) => void; + showContdPageBreak: boolean; + setShowContdPageBreak: (show: boolean) => void; headerLeft: string; setHeaderLeft: (template: string) => void; headerMiddle: string; @@ -253,6 +257,10 @@ const defaultContextValue: ProjectContextType = { setContdLabel: () => {}, moreLabel: "(MORE)", setMoreLabel: () => {}, + showContdDialogue: true, + setShowContdDialogue: () => {}, + showContdPageBreak: true, + setShowContdPageBreak: () => {}, headerLeft: "", setHeaderLeft: () => {}, headerMiddle: "", @@ -415,6 +423,8 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = const [sceneNumberOnRight, setSceneNumberOnRightState] = useState(false); const [contdLabel, setContdLabelState] = useState("(CONT'D)"); const [moreLabel, setMoreLabelState] = useState("(MORE)"); + const [showContdDialogue, setShowContdDialogueState] = useState(true); + const [showContdPageBreak, setShowContdPageBreakState] = useState(true); const [headerLeft, setHeaderLeftState] = useState(""); const [headerMiddle, setHeaderMiddleState] = useState(""); const [headerRight, setHeaderRightState] = useState("#."); @@ -620,6 +630,12 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (initialLayout.moreLabel !== undefined) { setMoreLabelState(initialLayout.moreLabel); } + if (initialLayout.showContdDialogue !== undefined) { + setShowContdDialogueState(initialLayout.showContdDialogue); + } + if (initialLayout.showContdPageBreak !== undefined) { + setShowContdPageBreakState(initialLayout.showContdPageBreak); + } if (initialLayout.headerLeft !== undefined) { setHeaderLeftState(initialLayout.headerLeft); } @@ -714,6 +730,12 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = if (_moreLabel !== undefined) { setMoreLabelState(_moreLabel); } + if (layout.showContdDialogue !== undefined) { + setShowContdDialogueState(layout.showContdDialogue); + } + if (layout.showContdPageBreak !== undefined) { + setShowContdPageBreakState(layout.showContdPageBreak); + } if (layout.headerLeft !== undefined) { setHeaderLeftState(layout.headerLeft); } @@ -979,6 +1001,22 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + const setShowContdDialogue = useCallback( + (show: boolean) => { + setShowContdDialogueState(show); + repository?.setShowContdDialogue(show); + }, + [repository], + ); + + const setShowContdPageBreak = useCallback( + (show: boolean) => { + setShowContdPageBreakState(show); + repository?.setShowContdPageBreak(show); + }, + [repository], + ); + const setHeaderLeft = useCallback( (template: string) => { setHeaderLeftState(template); @@ -1259,6 +1297,10 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setContdLabel, moreLabel, setMoreLabel, + showContdDialogue, + setShowContdDialogue, + showContdPageBreak, + setShowContdPageBreak, headerLeft, setHeaderLeft, headerMiddle, @@ -1365,6 +1407,10 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = setContdLabel, moreLabel, setMoreLabel, + showContdDialogue, + setShowContdDialogue, + showContdPageBreak, + setShowContdPageBreak, headerLeft, setHeaderLeft, headerMiddle, diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index de33b027..56dcc01f 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -20,6 +20,12 @@ export type PDFExportOptions = BaseExportOptions & { sceneNumberOnRight?: boolean; contdLabel?: string; moreLabel?: string; + /** Append the CONT'D label to a character cue resuming after an + * interruption. Defaults to on when omitted. */ + showContdDialogue?: boolean; + /** Draw the MORE / CONT'D pair around dialogue split by a page break. + * Defaults to on when omitted. */ + showContdPageBreak?: boolean; editorElement?: HTMLElement; titlePageElement?: HTMLElement; /** How production revisions are rendered into the PDF (see {@link RevisionExportMode}). */ @@ -235,6 +241,7 @@ export class PDFAdapter extends ProjectAdapter { pageMarginRight, contdLabel: options.contdLabel ?? "(CONT'D)", moreLabel: options.moreLabel ?? "(MORE)", + showContdPageBreak: options.showContdPageBreak !== false, }; worker.postMessage({ type: "START", payload }); @@ -830,7 +837,7 @@ export class PDFAdapter extends ProjectAdapter { } } - if (el.classList.contains("contd")) { + if (el.classList.contains("contd") && options.showContdDialogue !== false) { const label = options.contdLabel ?? "(CONT'D)"; if (lastLine.runs.length > 0) { const tailRun = lastLine.runs[lastLine.runs.length - 1]; diff --git a/src/lib/adapters/pdf/pdf.worker.ts b/src/lib/adapters/pdf/pdf.worker.ts index 556e6a08..554275ee 100644 --- a/src/lib/adapters/pdf/pdf.worker.ts +++ b/src/lib/adapters/pdf/pdf.worker.ts @@ -213,6 +213,8 @@ export interface WorkerPayload { pageMarginRight: number; contdLabel: string; moreLabel: string; + /** Draw the MORE / CONT'D pair around dialogue split by a page break. */ + showContdPageBreak: boolean; } self.onmessage = async (e: MessageEvent) => { @@ -343,7 +345,11 @@ async function renderLines( } const prevLine = findPrevContentLine(lines, li); const nextLine = findNextContentLine(lines, li); - const isDialogueSplit = prevLine?.type === "dialogue" && nextLine?.type === "dialogue"; + // Gating on the setting here suppresses the whole pair at once: the + // (MORE) below, the CHARACTER (CONT'D) above, and the line of space + // the latter would have taken from the new page. + const isDialogueSplit = + payload.showContdPageBreak && prevLine?.type === "dialogue" && nextLine?.type === "dialogue"; // If a dialogue block spans across the page break, draw (MORE) on this page if (isDialogueSplit && lastCharacterName) { diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index c07a9202..17ffdabb 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -88,6 +88,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum footerMiddle, footerRight, showFirstPageFooter, + showContdPageBreak, sceneLocking, sceneNumberingStyle, skippedSceneLetters, @@ -424,6 +425,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum : { footerLeft: "", footerMiddle: "", footerRight: "" }, }, ...SCREENPLAY_FORMATS[pageSize], + // Initial mount only; live toggles flow through + // updateShowContdPageBreak (see DocumentEditorPanel). + showContdPageBreak, getPageLocking: () => !!ext.pageLocking, getPageLocks: () => ext.persistentPages ?? {}, getSkippedLetters: () => ext.skippedSceneLetters ?? [], diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index 4a0eb989..6bcf45e5 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -103,6 +103,10 @@ export type LayoutData = { sceneNumberOnRight: boolean; contdLabel: string; moreLabel: string; + /** Append `(CONT'D)` to a character cue resuming after an interruption. */ + showContdDialogue: boolean; + /** Draw `(MORE)` / `(CONT'D)` around dialogue split by a page break. */ + showContdPageBreak: boolean; /** Page-header templates (left/middle/right) with `#`/`@`/`*` placeholders. */ headerLeft: string; headerMiddle: string; diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index 9687967c..3653a5ff 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -381,6 +381,14 @@ export class ProjectRepository { if (this.guardWrite("setMoreLabel")) return; this.ydoc.layout().set("moreLabel", label); } + setShowContdDialogue(show: boolean) { + if (this.guardWrite("setShowContdDialogue")) return; + this.ydoc.layout().set("showContdDialogue", show); + } + setShowContdPageBreak(show: boolean) { + if (this.guardWrite("setShowContdPageBreak")) return; + this.ydoc.layout().set("showContdPageBreak", show); + } setHeaderLeft(template: string) { if (this.guardWrite("setHeaderLeft")) return; this.ydoc.layout().set("headerLeft", template); diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index bb3d16f0..3464ed61 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -129,6 +129,10 @@ export interface PaginationOptions { customFooter: Record; /** Element types that force a page break before them. */ startNewPageTypes: Set; + /** Draw (MORE) / CHARACTER (CONT'D) around dialogue split by a page break. + * Off suppresses the overlays only — the break itself is unaffected, since + * they are absolutely positioned and never consume content space. */ + showContdPageBreak: boolean; /** * Production page-lock getters. When the editor is wired with page * locking, these expose the live toggle and lock map. Optional so test @@ -191,6 +195,7 @@ declare module "@tiptap/core" { updateFooterContent: (left: string, middle: string, right: string, pageNumber?: PageNumber) => ReturnType; updatePageBreakBackground: (color: string) => ReturnType; updateStartNewPageTypes: (types: Set) => ReturnType; + updateShowContdPageBreak: (show: boolean) => ReturnType; refreshPagination: () => ReturnType; /** Toggle the manual page-break flag on the top-level node at `pos`. * When set, pagination forces a new page that begins with that node. */ @@ -223,6 +228,7 @@ const defaultOptions: PaginationOptions = { customHeader: {}, customFooter: {}, startNewPageTypes: new Set(), + showContdPageBreak: true, }; // --------------------------------------------------------------------------- @@ -1312,6 +1318,8 @@ const createPaginationPlugin = (extension: { if (_mr) options.marginRight = parseFloat(_mr); const _snp = editorDOM.dataset.startNewPageTypes; if (_snp) options.startNewPageTypes = new Set(JSON.parse(_snp)); + const _scpb = editorDOM.dataset.showContdPageBreak; + if (_scpb) options.showContdPageBreak = _scpb === "true"; // Header/footer templates are bridged through the DOM (see // syncHeaderFooterData) because `options` here can lag the // command's mutations — without this, saved header/footer edits @@ -1510,7 +1518,7 @@ const createPaginationPlugin = (extension: { pagenum: ++pagenum, // + pageStartMargin: the ending page's first node was margin-stripped. freespace: Math.max(0, freespace + pageStartMargin), - contdName: logic?.showMoreContd ? lastCharName : "", + contdName: options.showContdPageBreak && logic?.showMoreContd ? lastCharName : "", splitNodeType: nodeType, anchorId: dataId, splitOffset, @@ -1602,7 +1610,7 @@ const createPaginationPlugin = (extension: { // + pageStartMargin: the ending page's first node was margin-stripped. freespace: Math.max(0, freespaceBeforeNode - split.topHeight + pageStartMargin), // contdName non-empty for dialogue: triggers (MORE)/(CONT'D) labels. - contdName: logic.showMoreContd ? lastCharName : "", + contdName: options.showContdPageBreak && logic.showMoreContd ? lastCharName : "", // splitNodeType drives the overlay padding-escape in createPageBreakWidget. splitNodeType: nodeType, // Anchor for page locking: the node being split owns both halves. @@ -1676,6 +1684,7 @@ const createPaginationPlugin = (extension: { // double-orphan), the whole block starts fresh — no labels needed. const firstMovingType = firstMovingNode?.type; const isDialogueSplit = + options.showContdPageBreak && lastCharName !== "" && (firstMovingType === ScreenplayElement.Dialogue || firstMovingType === ScreenplayElement.Parenthetical); @@ -2645,6 +2654,14 @@ export const ScriptioPagination = Extension.create({ tr.setMeta("forcePaginationUpdate", true); return true; }, + updateShowContdPageBreak: + (show) => + ({ tr }) => { + this.options.showContdPageBreak = show; + this.editor.view.dom.dataset.showContdPageBreak = show ? "true" : "false"; + tr.setMeta("forcePaginationUpdate", true); + return true; + }, refreshPagination: () => ({ tr }) => { diff --git a/styles/scriptio.css b/styles/scriptio.css index 1a5c69c3..27fe5e1b 100644 --- a/styles/scriptio.css +++ b/styles/scriptio.css @@ -403,6 +403,13 @@ content: " " var(--contd-label, "(CONT'D)"); } + /* CONT'D on resumed dialogue turned off in the layout settings. The + decoration class stays on the node (the PDF exporter gates on the same + setting); only the rendered suffix goes away. */ + &.hide-contd-dialogue .character.contd::after { + content: none; + } + /* Parenthetical */ .parenthetical { position: relative; From 38ec831549979dee450ea0686275072f166f592f Mon Sep 17 00:00:00 2001 From: Lycoon Date: Tue, 18 Aug 2026 00:51:33 +0200 Subject: [PATCH 6/6] cleaning pagination extension --- .../extensions/pagination-extension.ts | 166 +++++++----------- .../extensions/revisions-extension.ts | 5 +- 2 files changed, 65 insertions(+), 106 deletions(-) diff --git a/src/lib/screenplay/extensions/pagination-extension.ts b/src/lib/screenplay/extensions/pagination-extension.ts index 3464ed61..0b0f6f72 100644 --- a/src/lib/screenplay/extensions/pagination-extension.ts +++ b/src/lib/screenplay/extensions/pagination-extension.ts @@ -252,27 +252,24 @@ function syncVars(dom: HTMLElement, o: PaginationOptions) { } /** - * Bridge the header/footer templates through the editor DOM, the same way - * syncVars bridges page geometry. The plugin's `apply` reads `extension.options`, - * which can lag behind the synchronous mutations a command makes to - * `this.options` (Tiptap options-object identity issue). Writing the live values - * onto the DOM in the command and reading them back in `apply` guarantees the - * recompute sees the just-saved templates — without this, header/footer edits - * only appear after a full editor rebuild (page refresh). + * Per-editor clone backing `storage.live` — the mutable options object shared by + * the commands, the plugin and the lifecycle hooks. + * + * Do NOT swap this back to `this.options`: tiptap's `options` is a getter that + * returns a fresh shallow clone on every access, so a command's mutation is + * invisible to the clone the plugin captured. `storage` is cloned once per + * editor, which makes it the only shared channel between them. + * + * The nested containers are cloned too — tiptap's shallow spread shares them + * with the module-level `defaultOptions`, and so with every other editor. */ -function syncHeaderFooterData(dom: HTMLElement, o: PaginationOptions) { - dom.dataset.paginationHeader = JSON.stringify({ - headerLeft: o.headerLeft, - headerMiddle: o.headerMiddle, - headerRight: o.headerRight, - customHeader: o.customHeader, - }); - dom.dataset.paginationFooter = JSON.stringify({ - footerLeft: o.footerLeft, - footerMiddle: o.footerMiddle, - footerRight: o.footerRight, - customFooter: o.customFooter, - }); +function cloneLiveOptions(o: PaginationOptions): PaginationOptions { + return { + ...o, + startNewPageTypes: new Set(o.startNewPageTypes), + customHeader: { ...o.customHeader }, + customFooter: { ...o.customFooter }, + }; } // --------------------------------------------------------------------------- @@ -1047,10 +1044,9 @@ const setupTestDiv = (editorDom: HTMLElement, _: PaginationOptions): HTMLElement testDiv.className = editorDom.className; // Copy all CSS variables from the live editor DOM to the test div. This includes - // both element margin/style vars (set by DocumentEditorPanel) and page dimension - // vars (set by syncVars inside each command before the transaction is dispatched). - // Reading from editorDom rather than from options avoids the stale-options problem: - // extension.options in apply() may lag behind the mutation done by the command. + // both element margin/style vars (set by DocumentEditorPanel, which exist ONLY + // on the DOM — they are not pagination options) and page dimension vars (set by + // syncVars inside each command before the transaction is dispatched). for (let i = 0; i < editorDom.style.length; i++) { const prop = editorDom.style[i]; if (prop.startsWith("--")) { @@ -1224,9 +1220,8 @@ function computePageLabels( } const createPaginationPlugin = (extension: { - options: PaginationOptions; editor: Editor; - storage: { fontsReady: boolean }; + storage: { fontsReady: boolean; live: PaginationOptions }; }) => new Plugin({ key: paginationKey, @@ -1248,7 +1243,10 @@ const createPaginationPlugin = (extension: { // what eventually pulls us past this guard. if (!extension.storage.fontsReady) return value; - const options = extension.options as PaginationOptions; + // storage.live is the per-editor mutable options object every + // command writes to synchronously before dispatch — see + // cloneLiveOptions for why extension.options can't be used. + const options = extension.storage.live; const formatUpdate = tr.getMeta("pageFormatUpdate"); const forceUpdate = tr.getMeta("forcePaginationUpdate"); @@ -1300,53 +1298,6 @@ const createPaginationPlugin = (extension: { const editorDOM = extension.editor.view.dom as HTMLElement; - // extension.options may lag behind the synchronous mutations done by the - // commands (Tiptap options-object identity issue). editorDOM's inline style - // is always current because syncVars writes to it inside every command, - // before the transaction is dispatched. Override the stale option fields. - const _ph = editorDOM.style.getPropertyValue("--page-height"); - const _pw = editorDOM.style.getPropertyValue("--page-width"); - const _mt = editorDOM.style.getPropertyValue("--page-margin-top"); - const _mb = editorDOM.style.getPropertyValue("--page-margin-bottom"); - const _ml = editorDOM.style.getPropertyValue("--page-margin-left"); - const _mr = editorDOM.style.getPropertyValue("--page-margin-right"); - if (_ph) options.pageHeight = parseFloat(_ph); - if (_pw) options.pageWidth = parseFloat(_pw); - if (_mt) options.marginTop = parseFloat(_mt); - if (_mb) options.marginBottom = parseFloat(_mb); - if (_ml) options.marginLeft = parseFloat(_ml); - if (_mr) options.marginRight = parseFloat(_mr); - const _snp = editorDOM.dataset.startNewPageTypes; - if (_snp) options.startNewPageTypes = new Set(JSON.parse(_snp)); - const _scpb = editorDOM.dataset.showContdPageBreak; - if (_scpb) options.showContdPageBreak = _scpb === "true"; - // Header/footer templates are bridged through the DOM (see - // syncHeaderFooterData) because `options` here can lag the - // command's mutations — without this, saved header/footer edits - // wouldn't appear until a refresh. - const _hdr = editorDOM.dataset.paginationHeader; - if (_hdr) { - const h = JSON.parse(_hdr) as Pick< - PaginationOptions, - "headerLeft" | "headerMiddle" | "headerRight" | "customHeader" - >; - options.headerLeft = h.headerLeft; - options.headerMiddle = h.headerMiddle; - options.headerRight = h.headerRight; - options.customHeader = h.customHeader; - } - const _ftr = editorDOM.dataset.paginationFooter; - if (_ftr) { - const f = JSON.parse(_ftr) as Pick< - PaginationOptions, - "footerLeft" | "footerMiddle" | "footerRight" | "customFooter" - >; - options.footerLeft = f.footerLeft; - options.footerMiddle = f.footerMiddle; - options.footerRight = f.footerRight; - options.customFooter = f.customFooter; - } - const serializer = getSerializer(newState.schema); // --- Page-lock setup --- @@ -2037,7 +1988,7 @@ const createPaginationPlugin = (extension: { // guard — and skipping the O(doc) scan keeps it off the hot path. if (tr.getMeta(REVISION_STAMP_META)) return true; - const opts = extension.options as PaginationOptions; + const opts = extension.storage.live; if (!opts.getPageLocking?.()) return true; const pageLocks = opts.getPageLocks?.(); @@ -2178,6 +2129,11 @@ export const ScriptioPagination = Extension.create({ * with a fallback monospace font (Consolas etc.) that produces a * different line-wrap from CourierPrime. */ fontsReady: false, + /** The per-editor mutable options object. Commands write here (and + * mirror geometry to CSS vars via syncVars for rendering); the + * plugin's `apply` reads from here. See cloneLiveOptions for why + * `extension.options` cannot carry live mutations. */ + live: cloneLiveOptions(this.options), }; }, @@ -2185,8 +2141,7 @@ export const ScriptioPagination = Extension.create({ const editorDOM = this.editor.view.dom; editorDOM.classList.add("pagination"); - syncVars(editorDOM, this.options); - syncHeaderFooterData(editorDOM, this.options); + syncVars(editorDOM, this.storage.live); let style = document.getElementById("pagination-style"); if (!style) { @@ -2356,7 +2311,7 @@ export const ScriptioPagination = Extension.create({ } `; - setupTestDiv(editorDOM, this.options); + setupTestDiv(editorDOM, this.storage.live); // The screenplay @font-face fonts (CourierPrime + fallbacks) load // asynchronously. Until the real font is applied, the test div lays @@ -2460,7 +2415,7 @@ export const ScriptioPagination = Extension.create({ const { $from, empty } = state.selection; if (!empty || $from.parentOffset !== 0) return false; - const opts = this.options as PaginationOptions; + const opts = this.storage.live; if (!opts.getPageLocking?.()) return false; const pageLocks = opts.getPageLocks?.(); if (!pageLocks) return false; @@ -2546,7 +2501,7 @@ export const ScriptioPagination = Extension.create({ if (!empty || $from.parentOffset !== 0) return false; if ($from.parent.textContent.length === 0) return false; // nothing to push down - const opts = this.options as PaginationOptions; + const opts = this.storage.live; if (!opts.getPageLocking?.()) return false; const pageLocks = opts.getPageLocks?.(); if (!pageLocks) return false; @@ -2568,97 +2523,100 @@ export const ScriptioPagination = Extension.create({ }, addCommands() { + // Every command mutates `this.storage.live` — the per-editor shared + // options object the plugin's `apply` reads — never `this.options`, + // whose mutations are invisible outside this context (see + // cloneLiveOptions). Geometry changes are mirrored to CSS vars with + // syncVars because the stylesheet consumes them. return { updatePageSize: (size) => ({ tr }) => { - Object.assign(this.options, size); - syncVars(this.editor.view.dom, this.options); + Object.assign(this.storage.live, size); + syncVars(this.editor.view.dom, this.storage.live); tr.setMeta("pageFormatUpdate", true); return true; }, updatePageHeight: (h) => ({ tr }) => { - this.options.pageHeight = h; - syncVars(this.editor.view.dom, this.options); + this.storage.live.pageHeight = h; + syncVars(this.editor.view.dom, this.storage.live); tr.setMeta("pageFormatUpdate", true); return true; }, updatePageWidth: (w) => ({ tr }) => { - this.options.pageWidth = w; - syncVars(this.editor.view.dom, this.options); + this.storage.live.pageWidth = w; + syncVars(this.editor.view.dom, this.storage.live); tr.setMeta("pageFormatUpdate", true); return true; }, updatePageGap: (g) => ({ tr }) => { - this.options.pageGap = g; + this.storage.live.pageGap = g; tr.setMeta("forcePaginationUpdate", true); return true; }, updateMargins: (m) => ({ tr }) => { - Object.assign(this.options, { + Object.assign(this.storage.live, { marginTop: m.top, marginBottom: m.bottom, marginLeft: m.left, marginRight: m.right, }); - syncVars(this.editor.view.dom, this.options); + syncVars(this.editor.view.dom, this.storage.live); tr.setMeta("pageFormatUpdate", true); return true; }, updateHeaderContent: (l, m, r, p) => ({ tr }) => { - if (p !== undefined) this.options.customHeader[p] = { headerLeft: l, headerMiddle: m, headerRight: r }; + const live = this.storage.live; + if (p !== undefined) live.customHeader[p] = { headerLeft: l, headerMiddle: m, headerRight: r }; else { - this.options.headerLeft = l; - this.options.headerMiddle = m; - this.options.headerRight = r; + live.headerLeft = l; + live.headerMiddle = m; + live.headerRight = r; } - syncHeaderFooterData(this.editor.view.dom, this.options); tr.setMeta("forcePaginationUpdate", true); return true; }, updateFooterContent: (l, m, r, p) => ({ tr }) => { - if (p !== undefined) this.options.customFooter[p] = { footerLeft: l, footerMiddle: m, footerRight: r }; + const live = this.storage.live; + if (p !== undefined) live.customFooter[p] = { footerLeft: l, footerMiddle: m, footerRight: r }; else { - this.options.footerLeft = l; - this.options.footerMiddle = m; - this.options.footerRight = r; + live.footerLeft = l; + live.footerMiddle = m; + live.footerRight = r; } - syncHeaderFooterData(this.editor.view.dom, this.options); tr.setMeta("forcePaginationUpdate", true); return true; }, updatePageBreakBackground: (c) => ({ tr }) => { - this.options.pageBreakBackground = c; + this.storage.live.pageBreakBackground = c; tr.setMeta("forcePaginationUpdate", true); return true; }, updateStartNewPageTypes: (types) => ({ tr }) => { - this.options.startNewPageTypes = types; - this.editor.view.dom.dataset.startNewPageTypes = JSON.stringify([...types]); + this.storage.live.startNewPageTypes = types; tr.setMeta("forcePaginationUpdate", true); return true; }, updateShowContdPageBreak: (show) => ({ tr }) => { - this.options.showContdPageBreak = show; - this.editor.view.dom.dataset.showContdPageBreak = show ? "true" : "false"; + this.storage.live.showContdPageBreak = show; tr.setMeta("forcePaginationUpdate", true); return true; }, diff --git a/src/lib/screenplay/extensions/revisions-extension.ts b/src/lib/screenplay/extensions/revisions-extension.ts index 30446c32..83e1bfc4 100644 --- a/src/lib/screenplay/extensions/revisions-extension.ts +++ b/src/lib/screenplay/extensions/revisions-extension.ts @@ -17,6 +17,7 @@ import { revisionColor, } from "../revisions"; import { paginationKey } from "./pagination-extension"; +import { timeApply } from "./apply-timing"; /** Key of the revisions plugin; its state is the {@link Pending} edit set. * Exported so tests can assert that a flush consumed it. */ @@ -1169,7 +1170,7 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { // debounce (see the view below), keeping the key event free. state: { init: () => EMPTY_PENDING, - apply(tr, value, oldState, newState) { + apply: timeApply("revisions", (tr, value, oldState, newState) => { // Our debounced flush landed → pending is now applied. if (tr.getMeta(REVISION_STAMP_META)) return EMPTY_PENDING; if (!getRevisionsEnabled() || getCurrentRevision() < 1) { @@ -1218,7 +1219,7 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { gone, dirty: true, }; - }, + }), }, props: {