From 5f0fe06bbc297ef6fe9c58cb4162cac72c5d7979 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 12:41:05 +0000 Subject: [PATCH 01/10] build(deps): bump logback from 1.6.1 to 1.6.3 (#7622) Bumps `logback` from 1.6.1 to 1.6.3. Updates `ch.qos.logback:logback-core` from 1.6.1 to 1.6.3
Release notes

Sourced from ch.qos.logback:logback-core's releases.

Logback 1.6.3

2026-08-14 Release of logback version 1.6.3

-- Sponsoring SLF4J/logback/reload4j at https://github.com/sponsors/qos-ch

Logback 1.6.2

https://github.com/user-attachments/assets/9ceaf157-b758-4188-815d-edfe4e1b4edd

2026-08-10 Release of logback version 1.6.2

Commits

Updates `ch.qos.logback:logback-classic` from 1.6.1 to 1.6.3
Release notes

Sourced from ch.qos.logback:logback-classic's releases.

Logback 1.6.3

2026-08-14 Release of logback version 1.6.3

-- Sponsoring SLF4J/logback/reload4j at https://github.com/sponsors/qos-ch

Logback 1.6.2

https://github.com/user-attachments/assets/9ceaf157-b758-4188-815d-edfe4e1b4edd

2026-08-10 Release of logback version 1.6.2

Commits

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8b6dee0262..eb4b568f42 100644 --- a/build.gradle +++ b/build.gradle @@ -30,7 +30,7 @@ ext { openSamlVersion = "5.2.1" commonmarkVersion = "0.28.0" googleJavaFormatVersion = "1.35.0" - logback = "1.6.1" + logback = "1.6.3" commonsIoVersion = "2.22.0" commonsLang3 = "3.20.0" rhinoVersion = "1.9.1" From 1df372764fe9a21516df83e8aeacf32da6efc236 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:07:35 +0000 Subject: [PATCH 02/10] Mobile follow-ups to #7518: tool-list search, and drop the empty overflow menu (#7660) # Description of Changes Follow-up to #7518, picking up two mobile rough edges found while going over that branch. Two changes, one commit each. ## 1. Tool search back in the tool list (mobile) Tool search lives in the workbench bar's super search, which on mobile sits on the Workspace slide. So searching for a tool meant swiping off the tool list, typing, then swiping back. This puts a filter at the head of the tool panel on mobile. Reuses the existing `ToolSearch` component in `mode="filter"`, the same one the desktop fullscreen picker uses. Drives `setSearchQuery` on `ToolWorkflowContext`, so the query, filtering and grouped results are all existing paths. `ToolPanel` takes a new `showSearch` prop; `RightSidebar` passes `showSearch={isMobile}`. Desktop renders exactly as before. **To test:** - Open the editor at a phone-width viewport (under 1024px). - A "Search tools..." field should sit above Favourites / Recommended in the Tools pane. - Typing filters into grouped results. Clearing goes back to the compact list. - It hides once a tool is open, and comes back on the way out. - On desktop the field should not appear at all. ## 2. The mobile overflow menu opened with nothing in it `WorkbenchBarMobileActions` rendered its kebab trigger unconditionally. But every item inside is gated on `currentView === "viewer"` or `!isCustomView`. In a `custom:*` workbench both are false, so the dropdown was empty. `WorkbenchBarDesktopActions` renders nothing in that case, so this only showed on phones. Now returns `null` when neither group applies, with the two conditions named so the trigger and the items can't drift apart again. **To test:** - Phone-width viewport, load a PDF. - Open a tool with its own workbench view: Compare, Get Info report, Show JS, Validate Signature, Edit Table of Contents, or PDF Text Editor. - The kebab at the right of the workbench bar should be gone entirely, rather than opening an empty menu. - Back in the viewer or page editor it should still be there, with Print / Download / Save As / Close. --- .../WorkbenchBarMobileActions.tsx | 15 +++++++--- .../core/components/tools/RightSidebar.tsx | 3 ++ .../src/core/components/tools/ToolPanel.css | 11 +++++++ .../src/core/components/tools/ToolPanel.tsx | 30 +++++++++++++++++-- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx index 6d1cf0efdf..7675c37e33 100644 --- a/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx +++ b/frontend/editor/src/core/components/shared/workbenchBar/WorkbenchBarMobileActions.tsx @@ -25,6 +25,13 @@ export default function WorkbenchBarMobileActions({ }: WorkbenchBarActionsProps) { const { t } = useTranslation(); const exportDisabled = actionsDisabled || policyEnforcing; + const showPrint = currentView === "viewer"; + const showFileActions = !isCustomView; + + // Custom workbench views own their content, so none of these apply. The + // desktop cluster renders nothing at all in that case; without this the + // trigger would still be there, opening an empty dropdown. + if (!showPrint && !showFileActions) return null; return ( @@ -39,7 +46,7 @@ export default function WorkbenchBarMobileActions({ - {currentView === "viewer" && ( + {showPrint && ( } disabled={exportDisabled} @@ -48,7 +55,7 @@ export default function WorkbenchBarMobileActions({ {t("workbenchBar.print", "Print PDF")} )} - {!isCustomView && ( + {showFileActions && ( )} - {!isCustomView && saveAsIconName && ( + {showFileActions && saveAsIconName && ( @@ -74,7 +81,7 @@ export default function WorkbenchBarMobileActions({ {t("workbenchBar.saveAs", "Save As")} )} - {!isCustomView && ( + {showFileActions && ( <> diff --git a/frontend/editor/src/core/components/tools/ToolPanel.css b/frontend/editor/src/core/components/tools/ToolPanel.css index 427375db1a..3e3985103f 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.css +++ b/frontend/editor/src/core/components/tools/ToolPanel.css @@ -183,6 +183,17 @@ } } +/* In-panel tool filter. Aligned with .tool-picker__compact's inline padding so + the field lines up with the tool rows underneath it. */ +.tool-panel__search { + flex-shrink: 0; + padding: 0.5rem var(--mantine-spacing-sm) 0; +} + +.tool-panel__search .search-input-container { + margin: 0; +} + .tool-panel__compact-header-actions { display: flex; align-items: center; diff --git a/frontend/editor/src/core/components/tools/ToolPanel.tsx b/frontend/editor/src/core/components/tools/ToolPanel.tsx index 2d6006a8eb..d51ec9e0bf 100644 --- a/frontend/editor/src/core/components/tools/ToolPanel.tsx +++ b/frontend/editor/src/core/components/tools/ToolPanel.tsx @@ -4,6 +4,7 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import ToolPicker from "@app/components/tools/ToolPicker"; import SearchResults from "@app/components/tools/SearchResults"; import ToolRenderer from "@app/components/tools/ToolRenderer"; +import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar"; import { ToolId } from "@app/types/toolId"; @@ -20,6 +21,11 @@ interface ToolPanelProps { onToolSelect?: (id: ToolId) => void; /** Whether to render the compact (favourites + recommended only) view. */ compact?: boolean; + /** + * Render a tool filter at the head of the panel. Set where the workbench + * bar's super search is out of reach, so the list stays searchable in place. + */ + showSearch?: boolean; } /** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */ @@ -28,24 +34,44 @@ export default function ToolPanel({ onShowAllTools, onToolSelect, compact: compactProp, + showSearch = false, }: ToolPanelProps) { const { t } = useTranslation(); const { leftPanelView, searchQuery, + setSearchQuery, filteredTools, + toolRegistry, selectedToolKey, handleToolSelect, setPreviewFile, } = useToolWorkflow(); const selectTool = onToolSelect ?? handleToolSelect; + // Only offer the filter over the list itself; once a tool is open the panel + // belongs to that tool. Deriving the results branch from the same flag keeps + // the input and what it filters from drifting apart. + const panelSearch = showSearch && leftPanelView === "toolPicker"; + const searching = searchQuery.trim().length > 0; + return ( <> {/* Viewer mode tools — annotate, redact, form fill */} - {allToolsView && searchQuery.trim().length > 0 ? ( + {panelSearch && ( +
+ +
+ )} + + {searching && (allToolsView || panelSearch) ? (
selectTool(id as ToolId)} filteredTools={filteredTools} - isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)} + isSearching={searching} compact={compactProp ?? !allToolsView} onShowAllTools={onShowAllTools} /> From 629f501e9c9f070a90bb41f3f6dd213d7b900184 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 24 Aug 2026 10:33:50 +0000 Subject: [PATCH 03/10] Fix `any` type usages in frontend (#7617) # Description of Changes Follow-on from #7334. Fix more `any` type usages and ban them in the linter. We're starting to get down to only difficult folders left now, so some of these fixes replace an excluded folder with a couple of individual files to reduce scope to manageable levels. There are two real behaviour changes in this PR because of bugs that were never caught due to the lack of proper typing: - In the Google Drive service, `lastModified` was always `undefined` because it should have been read via `lastModifiedUtc`, which it now is. This means that files being read from Google Drive should now accurately retain their last modified date from Drive. - In the error toasts, there was translation logic to try and make friendlier error messages, but it'd never actually fire since it relied on `i18n` being written to `globalThis`, which it never was. It now imports the singleton instead so that translation should start working. I also had to tweak the way that FitText works because it was relying on `any` typing to mix refs between different places where they weren't technically compatible but I've changed it to go via a function and the behaviour doesn't change. --- .../core/components/shared/BulkShareModal.tsx | 2 +- .../core/components/shared/CardSelector.tsx | 5 +- .../components/shared/FileSelectorPicker.tsx | 4 +- .../core/components/shared/FileSidebar.tsx | 4 +- .../components/shared/FirstLoginModal.tsx | 8 +- .../src/core/components/shared/FitText.tsx | 14 +++- .../core/components/shared/ShareFileModal.tsx | 2 +- .../shared/ShareManagementModal.tsx | 2 +- .../src/core/components/shared/Tooltip.tsx | 72 +++++++++++------- .../tools/automate/AutomationCreation.tsx | 2 +- .../tools/automate/AutomationEntry.tsx | 2 +- .../tools/automate/AutomationRun.tsx | 9 ++- .../tools/automate/ToolSelector.tsx | 11 +-- .../core/components/viewer/EmbedPdfViewer.tsx | 3 +- .../components/viewer/SelectionAPIBridge.tsx | 1 - .../src/core/contexts/viewer/viewerActions.ts | 18 ++--- .../src/core/contexts/viewer/viewerBridges.ts | 9 ++- .../src/core/services/apiClientConfig.ts | 10 +-- .../editor/src/core/services/auditService.ts | 2 +- .../src/core/services/automationStorage.ts | 2 +- .../services/enhancedPDFProcessingService.ts | 5 +- .../editor/src/core/services/errorUtils.ts | 19 ++--- .../services/fileStorage.migration.test.ts | 2 +- .../src/core/services/fileSyncService.ts | 6 +- .../core/services/googleDrivePickerService.ts | 75 +++++++++++-------- .../src/core/services/httpErrorHandler.ts | 16 ++-- .../src/core/services/httpErrorUtils.ts | 14 ++-- .../src/core/services/pdfWorkerManager.ts | 4 +- .../services/signatureDetectionService.ts | 15 ++-- .../core/services/signatureStorageService.ts | 10 ++- .../src/core/services/specialErrorToasts.ts | 18 ++--- .../core/services/usageAnalyticsService.ts | 2 +- frontend/editor/src/global.d.ts | 1 + frontend/oxlint.config.ts | 7 +- 34 files changed, 209 insertions(+), 167 deletions(-) diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.tsx index e210d2f201..f68a154e89 100644 --- a/frontend/editor/src/core/components/shared/BulkShareModal.tsx +++ b/frontend/editor/src/core/components/shared/BulkShareModal.tsx @@ -153,7 +153,7 @@ const BulkShareModal: React.FC = ({ if (onShared) { await onShared(); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to generate share link:", error); setErrorMessage( t( diff --git a/frontend/editor/src/core/components/shared/CardSelector.tsx b/frontend/editor/src/core/components/shared/CardSelector.tsx index 75e9071d81..79a94e7b40 100644 --- a/frontend/editor/src/core/components/shared/CardSelector.tsx +++ b/frontend/editor/src/core/components/shared/CardSelector.tsx @@ -1,5 +1,6 @@ import { Stack, Card, Text, Flex } from "@mantine/core"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { TooltipTip } from "@app/types/tips"; import { useTranslation } from "react-i18next"; export interface CardOption { @@ -7,14 +8,14 @@ export interface CardOption { prefixKey: string; nameKey: string; tooltipKey?: string; - tooltipContent?: any[]; + tooltipContent?: TooltipTip[]; } export interface CardSelectorProps> { options: K[]; onSelect: (value: T) => void; disabled?: boolean; - getTooltipContent?: (option: K) => any[]; + getTooltipContent?: (option: K) => TooltipTip[]; } const CardSelector = >({ diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx index 17502ee9b6..2b84105ba8 100644 --- a/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.tsx @@ -269,7 +269,7 @@ export function FileSelectorPicker({ responseType: "blob", suppressErrorToast: true, skipAuthRedirect: true, - } as any, + }, ); const ct = readResponseHeader(res.headers, "content-type"); const disp = readResponseHeader(res.headers, "content-disposition"); @@ -287,7 +287,7 @@ export function FileSelectorPicker({ responseType: "blob", suppressErrorToast: true, skipAuthRedirect: true, - } as any, + }, ); const ct = readResponseHeader(res.headers, "content-type"); const disp = readResponseHeader(res.headers, "content-disposition"); diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index 3979a40041..f9337bd880 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -208,7 +208,7 @@ const FileSidebar = forwardRef( const openWatchedFolders = useCallback(() => { if (collapsed && onToggleCollapse) onToggleCollapse(); setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId: null }); - navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any); + navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID); }, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions]); // Clicking a file's membership dot jumps straight into that folder. @@ -216,7 +216,7 @@ const FileSidebar = forwardRef( (folderId: string) => { if (collapsed && onToggleCollapse) onToggleCollapse(); setCustomWorkbenchViewData(WATCHED_FOLDER_VIEW_ID, { folderId }); - navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID as any); + navActions.setWorkbench(WATCHED_FOLDER_WORKBENCH_ID); }, [collapsed, onToggleCollapse, setCustomWorkbenchViewData, navActions], ); diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx index be91ccc03d..8d2e6a7c67 100644 --- a/frontend/editor/src/core/components/shared/FirstLoginModal.tsx +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import axios from "axios"; import { Modal, Stack, Text, PasswordInput, Alert } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { useTranslation } from "react-i18next"; @@ -95,10 +96,13 @@ export default function FirstLoginModal({ setTimeout(() => { onPasswordChanged(); }, 1500); - } catch (err: any) { + } catch (err: unknown) { console.error("Failed to change password:", err); + const message = axios.isAxiosError<{ message?: string }>(err) + ? err.response?.data?.message + : undefined; setError( - err.response?.data?.message || + message || t( "firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password.", diff --git a/frontend/editor/src/core/components/shared/FitText.tsx b/frontend/editor/src/core/components/shared/FitText.tsx index c3a2ead2f2..97afc8fdb9 100644 --- a/frontend/editor/src/core/components/shared/FitText.tsx +++ b/frontend/editor/src/core/components/shared/FitText.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useMemo, useRef } from "react"; +import React, { CSSProperties, useCallback, useMemo, useRef } from "react"; import { useAdjustFontSizeToFit } from "@app/components/shared/fitText/textFit"; type FitTextProps = { @@ -28,8 +28,14 @@ const FitText: React.FC = ({ }) => { const ref = useRef(null); + // Callback ref: an HTMLElement handler satisfies span's/div's differing ref + // types (callback refs are contravariant), so the tag can stay polymorphic. + const setRef = useCallback((node: HTMLElement | null) => { + ref.current = node; + }, []); + // Hook runs after mount and on size/text changes; uses observers internally - useAdjustFontSizeToFit(ref as any, { + useAdjustFontSizeToFit(ref, { maxFontSizePx: fontSize, minFontScale: minimumFontScale, maxLines: lines, @@ -38,7 +44,7 @@ const FitText: React.FC = ({ // Memoize the HTML tag to render (span/div) from the `as` prop so // React doesn't create a new component function on each render. - const ElementTag: any = useMemo(() => as, [as]); + const ElementTag: React.ElementType = useMemo(() => as, [as]); // For the / character, insert zero-width soft breaks to prefer wrapping at them const displayText = useMemo(() => { @@ -70,7 +76,7 @@ const FitText: React.FC = ({ return ( diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.tsx index 1430f9df20..009345c274 100644 --- a/frontend/editor/src/core/components/shared/ShareFileModal.tsx +++ b/frontend/editor/src/core/components/shared/ShareFileModal.tsx @@ -165,7 +165,7 @@ const ShareFileModal: React.FC = ({ if (onUploaded) { await onUploaded(); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to generate share link:", error); setErrorMessage( t( diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx index e0e5899f8d..64760da8d7 100644 --- a/frontend/editor/src/core/components/shared/ShareManagementModal.tsx +++ b/frontend/editor/src/core/components/shared/ShareManagementModal.tsx @@ -226,7 +226,7 @@ const ShareManagementModal: React.FC = ({ durationMs: 2500, }); } - } catch (error: any) { + } catch (error: unknown) { console.error("Failed to create share link:", error); setErrorMessage( t( diff --git a/frontend/editor/src/core/components/shared/Tooltip.tsx b/frontend/editor/src/core/components/shared/Tooltip.tsx index 5403940751..55c06a1533 100644 --- a/frontend/editor/src/core/components/shared/Tooltip.tsx +++ b/frontend/editor/src/core/components/shared/Tooltip.tsx @@ -18,6 +18,18 @@ import { useLogoAssets } from "@app/hooks/useLogoAssets"; import styles from "@app/components/shared/tooltip/Tooltip.module.css"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +// The wrapped child's own event handlers, which Tooltip forwards to after +// running its own trigger logic. Kept partial since any given child may set none. +interface ForwardedHandlers { + onPointerEnter?: React.PointerEventHandler; + onPointerLeave?: React.PointerEventHandler; + onMouseDown?: React.MouseEventHandler; + onMouseUp?: React.MouseEventHandler; + onClick?: React.MouseEventHandler; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; +} + export interface TooltipProps { sidebarTooltip?: boolean; position?: "right" | "left" | "top" | "bottom"; @@ -218,7 +230,7 @@ export const Tooltip: React.FC = ({ const handlePointerEnter = useCallback( (e: React.PointerEvent) => { if (!isPinned && !disabled) openWithDelay(); - (children.props as any)?.onPointerEnter?.(e); + (children.props as ForwardedHandlers).onPointerEnter?.(e); }, [isPinned, openWithDelay, children.props, disabled], ); @@ -233,19 +245,19 @@ export const Tooltip: React.FC = ({ tooltipRef.current && tooltipRef.current.contains(related) ) { - (children.props as any)?.onPointerLeave?.(e); + (children.props as ForwardedHandlers).onPointerLeave?.(e); return; } // Ignore transient leave between mousedown and click if (clickPendingRef.current) { - (children.props as any)?.onPointerLeave?.(e); + (children.props as ForwardedHandlers).onPointerLeave?.(e); return; } clearTimers(); if (allowAutoClose && !isPinned) setOpen(false); - (children.props as any)?.onPointerLeave?.(e); + (children.props as ForwardedHandlers).onPointerLeave?.(e); }, [clearTimers, isPinned, setOpen, children.props, allowAutoClose], ); @@ -253,7 +265,7 @@ export const Tooltip: React.FC = ({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { clickPendingRef.current = true; - (children.props as any)?.onMouseDown?.(e); + (children.props as ForwardedHandlers).onMouseDown?.(e); }, [children.props], ); @@ -262,7 +274,7 @@ export const Tooltip: React.FC = ({ (e: React.MouseEvent) => { // allow microtask turn so click can see this false queueMicrotask(() => (clickPendingRef.current = false)); - (children.props as any)?.onMouseUp?.(e); + (children.props as ForwardedHandlers).onMouseUp?.(e); }, [children.props], ); @@ -279,7 +291,7 @@ export const Tooltip: React.FC = ({ return; } clickPendingRef.current = false; - (children.props as any)?.onClick?.(e); + (children.props as ForwardedHandlers).onClick?.(e); }, [clearTimers, pinOnClick, open, setOpen, children.props], ); @@ -288,7 +300,7 @@ export const Tooltip: React.FC = ({ const handleFocus = useCallback( (e: React.FocusEvent) => { if (!isPinned && !disabled && openOnFocus) openWithDelay(); - (children.props as any)?.onFocus?.(e); + (children.props as ForwardedHandlers).onFocus?.(e); }, [isPinned, openWithDelay, children.props, disabled, openOnFocus], ); @@ -301,12 +313,12 @@ export const Tooltip: React.FC = ({ tooltipRef.current && tooltipRef.current.contains(related) ) { - (children.props as any)?.onBlur?.(e); + (children.props as ForwardedHandlers).onBlur?.(e); return; } clearTimers(); if (allowAutoClose && !isPinned) setOpen(false); - (children.props as any)?.onBlur?.(e); + (children.props as ForwardedHandlers).onBlur?.(e); }, [isPinned, setOpen, children.props, allowAutoClose, clearTimers], ); @@ -339,24 +351,30 @@ export const Tooltip: React.FC = ({ ); // Enhance child with handlers and ref - const childWithHandlers = React.cloneElement(children as any, { - ref: (node: HTMLElement | null) => { - triggerRef.current = node || null; - const originalRef = (children as any).ref; - if (typeof originalRef === "function") originalRef(node); - else if (originalRef && typeof originalRef === "object") - (originalRef as any).current = node; + const childWithHandlers = React.cloneElement( + children as React.ReactElement>, + { + ref: (node: HTMLElement | null) => { + triggerRef.current = node || null; + const originalRef = ( + children as React.ReactElement & { ref?: React.Ref } + ).ref; + if (typeof originalRef === "function") originalRef(node); + else if (originalRef && typeof originalRef === "object") + (originalRef as React.MutableRefObject).current = + node; + }, + "aria-describedby": open ? tooltipIdRef.current : undefined, + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onClick: handleClick, + onFocus: handleFocus, + onBlur: handleBlur, + onKeyDown: handleKeyDown, }, - "aria-describedby": open ? tooltipIdRef.current : undefined, - onPointerEnter: handlePointerEnter, - onPointerLeave: handlePointerLeave, - onMouseDown: handleMouseDown, - onMouseUp: handleMouseUp, - onClick: handleClick, - onFocus: handleFocus, - onBlur: handleBlur, - onKeyDown: handleKeyDown, - }); + ); const shouldShowTooltip = open; const shouldShowCloseButton = showCloseButton || isPinned; diff --git a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx index 65c50a3481..0e4926adbe 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationCreation.tsx @@ -82,7 +82,7 @@ export default function AutomationCreation({ setConfigModalOpen(true); }; - const handleToolConfigSave = (parameters: Record) => { + const handleToolConfigSave = (parameters: Record) => { if (configuraingToolIndex >= 0) { updateTool(configuraingToolIndex, { configured: true, diff --git a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx index 8ffbfeda81..a4a7c1d2ca 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationEntry.tsx @@ -19,7 +19,7 @@ interface AutomationEntryProps { /** Optional description for tooltip */ description?: string; /** MUI Icon component for the badge */ - badgeIcon?: React.ComponentType; + badgeIcon?: React.ComponentType; /** Array of tool operation names in the workflow */ operations: string[]; /** Click handler */ diff --git a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx index 2e3e5f1539..675372c12b 100644 --- a/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/editor/src/core/components/tools/automate/AutomationRun.tsx @@ -9,11 +9,12 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { AutomationConfig, ExecutionStep } from "@app/types/automation"; import { EXECUTION_STATUS } from "@app/constants/automation"; import { useResourceCleanup } from "@app/utils/resourceManager"; +import type { useAutomateOperation } from "@app/hooks/tools/automate/useAutomateOperation"; interface AutomationRunProps { automation: AutomationConfig; onComplete: () => void; - automateOperation?: any; // TODO: Type this properly when available + automateOperation?: ReturnType; } export default function AutomationRun({ @@ -34,13 +35,13 @@ export default function AutomationRun({ // Use the operation hook's loading state const isExecuting = automateOperation?.isLoading || false; const hasResults = - automateOperation?.files.length > 0 || + (automateOperation?.files.length ?? 0) > 0 || automateOperation?.downloadUrl !== null; // Initialize execution steps from automation useEffect(() => { if (automation?.operations) { - const steps = automation.operations.map((op: any, index: number) => { + const steps = automation.operations.map((op, index) => { const tool = toolRegistry[op.operation as keyof typeof toolRegistry]; return { id: `${op.operation}-${index}`, @@ -125,7 +126,7 @@ export default function AutomationRun({ // Mark all as completed and reset current step setCurrentStepIndex(-1); console.log(`✅ Automation completed successfully`); - } catch (error: any) { + } catch (error: unknown) { console.error("Automation execution failed:", error); setCurrentStepIndex(-1); } diff --git a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx index ad7bb33a6e..2dd482863d 100644 --- a/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx +++ b/frontend/editor/src/core/components/tools/automate/ToolSelector.tsx @@ -4,6 +4,7 @@ import { Stack, Text, ScrollArea } from "@mantine/core"; import { ToolRegistryEntry, ToolRegistry, + SubcategoryId, getToolSupportsAutomate, } from "@app/data/toolsTaxonomy"; import { useToolSections } from "@app/hooks/useToolSections"; @@ -81,9 +82,7 @@ export default function ToolSelector({ }, [filteredTools]); // Use the same tool sections logic as the main ToolPicker - const { sections, searchGroups } = useToolSections( - transformedFilteredTools as any /* FIX ME */, - ); + const { sections, searchGroups } = useToolSections(transformedFilteredTools); // Determine what to display: search results or organized sections const isSearching = searchTerm.trim().length > 0; @@ -98,7 +97,9 @@ export default function ToolSelector({ return [ { name: "Tools", - subcategoryId: "all" as any, + // Synthetic "all tools" group used only as a fallback when the + // taxonomy produces no sections; "all" is not a real SubcategoryId. + subcategoryId: "all" as unknown as SubcategoryId, tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })), }, ]; @@ -125,7 +126,7 @@ export default function ToolSelector({ displayGroups.map((subcategory) => renderToolButtons( t, - subcategory as any, + subcategory, null, handleToolSelect, !isSearching, diff --git a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx index 187a48ad16..80feb0f91e 100644 --- a/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/editor/src/core/components/viewer/EmbedPdfViewer.tsx @@ -13,6 +13,7 @@ import { useFileActions, } from "@app/contexts/FileContext"; import { useFileWithUrl } from "@app/hooks/useFileWithUrl"; +import { ZoomMode } from "@embedpdf/plugin-zoom/react"; import { useViewer } from "@app/contexts/ViewerContext"; import { LocalEmbedPDF } from "@app/components/viewer/LocalEmbedPDF"; import { PdfViewerToolbar } from "@app/components/viewer/PdfViewerToolbar"; @@ -418,7 +419,7 @@ const EmbedPdfViewerContent = ({ return; case "0": event.preventDefault(); - zoomActions.requestZoom("fit-width"); + zoomActions.requestZoom(ZoomMode.FitWidth); return; } } diff --git a/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx b/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx index 36d0c59cf7..3efb250de1 100644 --- a/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx +++ b/frontend/editor/src/core/components/viewer/SelectionAPIBridge.tsx @@ -118,7 +118,6 @@ export function SelectionAPIBridge() { const buildApi = () => ({ copyToClipboard: () => selection.copyToClipboard(), - getSelectedText: () => selection.getSelectedText(), getFormattedSelection: () => selection.getFormattedSelection(), selectAll: async (totalPages: number) => { const docId = activeDocumentId; diff --git a/frontend/editor/src/core/contexts/viewer/viewerActions.ts b/frontend/editor/src/core/contexts/viewer/viewerActions.ts index 2e0231d766..ae5679c9b6 100644 --- a/frontend/editor/src/core/contexts/viewer/viewerActions.ts +++ b/frontend/editor/src/core/contexts/viewer/viewerActions.ts @@ -6,6 +6,8 @@ import { ZoomState, } from "@app/contexts/viewer/viewerBridges"; import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models"; +import { ZoomLevel, Point } from "@embedpdf/plugin-zoom"; +import { FormattedSelection } from "@embedpdf/plugin-selection"; export interface ScrollActions { scrollToPage: (page: number, behavior?: "smooth" | "instant") => void; @@ -19,7 +21,7 @@ export interface ZoomActions { zoomIn: () => void; zoomOut: () => void; toggleMarqueeZoom: () => void; - requestZoom: (level: any, center?: any) => void; + requestZoom: (level: ZoomLevel, center?: Point) => void; setZoomLevel: (factor: number) => void; } @@ -31,8 +33,7 @@ export interface PanActions { export interface SelectionActions { copyToClipboard: () => void; - getSelectedText: () => string; - getFormattedSelection: () => any; + getFormattedSelection: () => FormattedSelection[] | null; selectAll: (totalPages: number) => Promise; selectWordAt: (pageIndex: number, x: number, y: number) => boolean; } @@ -51,7 +52,7 @@ export interface RotationActions { } export interface SearchActions { - search: (query: string) => Promise | undefined; + search: (query: string) => Promise | undefined; next: () => void; previous: () => void; clear: () => void; @@ -214,7 +215,7 @@ export function createViewerActions({ api.toggleMarqueeZoom(); } }, - requestZoom: (level: any, center?: any) => { + requestZoom: (level: ZoomLevel, center?: Point) => { const api = registry.current.zoom?.api; if (api?.requestZoom) { api.requestZoom(level, center); @@ -257,13 +258,6 @@ export function createViewerActions({ api.copyToClipboard(); } }, - getSelectedText: () => { - const api = registry.current.selection?.api; - if (api?.getSelectedText) { - return api.getSelectedText() ?? ""; - } - return ""; - }, getFormattedSelection: () => { const api = registry.current.selection?.api; if (api?.getFormattedSelection) { diff --git a/frontend/editor/src/core/contexts/viewer/viewerBridges.ts b/frontend/editor/src/core/contexts/viewer/viewerBridges.ts index c003013cb3..7a9f177e03 100644 --- a/frontend/editor/src/core/contexts/viewer/viewerBridges.ts +++ b/frontend/editor/src/core/contexts/viewer/viewerBridges.ts @@ -1,5 +1,7 @@ import { SpreadMode } from "@embedpdf/plugin-spread/react"; import { PdfBookmarkObject, PdfAttachmentObject } from "@embedpdf/models"; +import { ZoomLevel, Point } from "@embedpdf/plugin-zoom"; +import { FormattedSelection } from "@embedpdf/plugin-selection"; export enum PdfPermissionFlag { Print = 0x0004, @@ -46,7 +48,7 @@ export interface ZoomAPIWrapper { zoomIn: () => void; zoomOut: () => void; toggleMarqueeZoom: () => void; - requestZoom: (level: any, center?: any) => void; + requestZoom: (level: ZoomLevel, center?: Point) => void; } export interface PanAPIWrapper { @@ -58,8 +60,7 @@ export interface PanAPIWrapper { export interface SelectionAPIWrapper { copyToClipboard: () => void; - getSelectedText: () => string | any; - getFormattedSelection: () => any; + getFormattedSelection: () => FormattedSelection[]; selectAll: (totalPages: number) => Promise; selectWordAt: (pageIndex: number, x: number, y: number) => boolean; } @@ -79,7 +80,7 @@ export interface RotationAPIWrapper { } export interface SearchAPIWrapper { - search: (query: string) => Promise; + search: (query: string) => Promise; clear: () => void; next: () => void; previous: () => void; diff --git a/frontend/editor/src/core/services/apiClientConfig.ts b/frontend/editor/src/core/services/apiClientConfig.ts index b476d1348a..89fa465743 100644 --- a/frontend/editor/src/core/services/apiClientConfig.ts +++ b/frontend/editor/src/core/services/apiClientConfig.ts @@ -11,11 +11,11 @@ */ export function getApiBaseUrl(): string { // Runtime override to fix hardcoded localhost in builds - if ( - typeof window !== "undefined" && - (window as any).STIRLING_PDF_API_BASE_URL - ) { - return (window as any).STIRLING_PDF_API_BASE_URL; + if (typeof window !== "undefined") { + const override = window.STIRLING_PDF_API_BASE_URL; + if (override) { + return override; + } } return import.meta.env.VITE_API_BASE_URL; diff --git a/frontend/editor/src/core/services/auditService.ts b/frontend/editor/src/core/services/auditService.ts index c748504c54..acf43ab1d5 100644 --- a/frontend/editor/src/core/services/auditService.ts +++ b/frontend/editor/src/core/services/auditService.ts @@ -17,7 +17,7 @@ export interface AuditEvent { eventType: string; username: string; ipAddress: string; - details: Record; + details: Record; } export interface AuditEventsResponse { diff --git a/frontend/editor/src/core/services/automationStorage.ts b/frontend/editor/src/core/services/automationStorage.ts index 826bf3ce30..196596de18 100644 --- a/frontend/editor/src/core/services/automationStorage.ts +++ b/frontend/editor/src/core/services/automationStorage.ts @@ -8,7 +8,7 @@ export interface AutomationConfig { description?: string; operations: Array<{ operation: string; - parameters: any; + parameters: Record; }>; createdAt: string; updatedAt: string; diff --git a/frontend/editor/src/core/services/enhancedPDFProcessingService.ts b/frontend/editor/src/core/services/enhancedPDFProcessingService.ts index e91c34cde2..a63651e193 100644 --- a/frontend/editor/src/core/services/enhancedPDFProcessingService.ts +++ b/frontend/editor/src/core/services/enhancedPDFProcessingService.ts @@ -5,6 +5,7 @@ import { ProcessingConfig, ProcessingMetrics, } from "@app/types/processing"; +import type { PDFPageProxy } from "pdfjs-dist"; import { ProcessingCache } from "@app/services/processingCache"; import { FileHasher } from "@app/utils/fileHash"; import { FileAnalyzer } from "@app/services/fileAnalyzer"; @@ -415,7 +416,7 @@ export class EnhancedPDFProcessingService { * Render a page thumbnail with specified quality */ private async renderPageThumbnail( - page: any, + page: PDFPageProxy, quality: "low" | "medium" | "high", ): Promise { const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor @@ -431,7 +432,7 @@ export class EnhancedPDFProcessingService { throw new Error("Could not get canvas context"); } - await page.render({ canvasContext: context, viewport }).promise; + await page.render({ canvasContext: context, viewport, canvas }).promise; return canvas.toDataURL("image/jpeg", 0.8); // Use JPEG for better compression } diff --git a/frontend/editor/src/core/services/errorUtils.ts b/frontend/editor/src/core/services/errorUtils.ts index cdfeb9f38f..747cd90b5a 100644 --- a/frontend/editor/src/core/services/errorUtils.ts +++ b/frontend/editor/src/core/services/errorUtils.ts @@ -5,7 +5,7 @@ export const FILE_EVENTS = { const UUID_REGEX = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; -export function tryParseJson(input: unknown): T | undefined { +export function tryParseJson(input: unknown): T | undefined { if (typeof input !== "string") return input as T | undefined; try { return JSON.parse(input) as T; @@ -14,19 +14,20 @@ export function tryParseJson(input: unknown): T | undefined { } } -export async function normalizeAxiosErrorData(data: any): Promise { +export async function normalizeAxiosErrorData(data: unknown): Promise { if (!data) return undefined; - if (typeof data?.text === "function") { - const text = await data.text(); + const blobLike = data as { text?: () => Promise }; + if (typeof blobLike.text === "function") { + const text = await blobLike.text(); return tryParseJson(text) ?? text; } return data; } -export function extractErrorFileIds(payload: any): string[] | undefined { +export function extractErrorFileIds(payload: unknown): string[] | undefined { if (!payload) return undefined; - if (Array.isArray(payload?.errorFileIds)) - return payload.errorFileIds as string[]; + const errorFileIds = (payload as { errorFileIds?: unknown }).errorFileIds; + if (Array.isArray(errorFileIds)) return errorFileIds as string[]; if (typeof payload === "string") { const matches = payload.match(UUID_REGEX); if (matches && matches.length > 0) return Array.from(new Set(matches)); @@ -45,11 +46,11 @@ export function isZeroByte( file: File | { size?: number } | null | undefined, ): boolean { if (!file) return true; - const size = (file as any).size; + const size = file.size; return typeof size === "number" ? size <= 0 : true; } export function isEmptyOutput(files: File[] | null | undefined): boolean { if (!files || files.length === 0) return true; - return files.every((f) => (f as any)?.size === 0); + return files.every((f) => f?.size === 0); } diff --git a/frontend/editor/src/core/services/fileStorage.migration.test.ts b/frontend/editor/src/core/services/fileStorage.migration.test.ts index 472d83b49b..28ee69e8da 100644 --- a/frontend/editor/src/core/services/fileStorage.migration.test.ts +++ b/frontend/editor/src/core/services/fileStorage.migration.test.ts @@ -33,7 +33,7 @@ describe("legacyDerivedFromTool — IndexedDB backfill for pre-upgrade files", ( it("flags a legacy versioned edit (has tool history)", () => { expect( legacyDerivedFromTool( - record({ toolHistory: [{ toolId: "compress" as any, timestamp: 0 }] }), + record({ toolHistory: [{ toolId: "compress", timestamp: 0 }] }), ), ).toBe(true); }); diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index 5e948f8ba7..a218a43428 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -131,7 +131,7 @@ export async function reconcileServerFiles( { suppressErrorToast: true, skipAuthRedirect: true, - } as any, + }, ); const serverFiles = Array.isArray(response.data) ? response.data : []; const serverMap = new Map(); @@ -280,7 +280,7 @@ export async function reconcileServerFiles( try { const response = await apiClient.get( "/api/v1/storage/share-links/accessed", - { suppressErrorToast: true, skipAuthRedirect: true } as any, + { suppressErrorToast: true, skipAuthRedirect: true }, ); const sharedLinks = Array.isArray(response.data) ? response.data : []; const allowed = new Set( @@ -426,7 +426,7 @@ export async function materializeServerStubs( responseType: "blob", suppressErrorToast: true, skipAuthRedirect: true, - } as any); + }); const rawHeaders = (response.headers ?? {}) as Record & { get?: (name: string) => string | null; }; diff --git a/frontend/editor/src/core/services/googleDrivePickerService.ts b/frontend/editor/src/core/services/googleDrivePickerService.ts index 9d91ffdeac..f54e900b01 100644 --- a/frontend/editor/src/core/services/googleDrivePickerService.ts +++ b/frontend/editor/src/core/services/googleDrivePickerService.ts @@ -5,6 +5,13 @@ import { loadScript } from "@app/utils/scriptLoader"; import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { AppConfig } from "@app/types/appConfig"; + +// The GIS token client lets you reassign `callback` after init (to resolve the +// per-request promise), but @types/google.accounts only models it on the config. +type TokenClientWithCallback = google.accounts.oauth2.TokenClient & { + callback: (response: google.accounts.oauth2.TokenResponse) => void; +}; const SCOPES = "https://www.googleapis.com/auth/drive.readonly"; const SESSION_STORAGE_ID = "googleDrivePickerAccessToken"; @@ -59,7 +66,7 @@ function fileInputToGooglePickerMimeTypes(accept?: string): string | null { class GoogleDrivePickerService { private config: GoogleDriveConfig | null = null; - private tokenClient: any = null; + private tokenClient: TokenClientWithCallback | null = null; private accessToken: string | null = null; private gapiLoaded = false; private gisLoaded = false; @@ -119,7 +126,7 @@ class GoogleDrivePickerService { client_id: this.config.clientId, scope: SCOPES, callback: () => {}, // Will be overridden during picker creation - }); + }) as TokenClientWithCallback; this.gisLoaded = true; } @@ -149,7 +156,9 @@ class GoogleDrivePickerService { return; } - this.tokenClient.callback = (response: any) => { + this.tokenClient.callback = ( + response: google.accounts.oauth2.TokenResponse, + ) => { if (response.error !== undefined) { reject(new Error(response.error)); return; @@ -201,7 +210,9 @@ class GoogleDrivePickerService { .setOAuthToken(this.accessToken) .addView(view1) .addView(view2) - .setCallback((data: any) => this.pickerCallback(data, resolve, reject)); + .setCallback((data: google.picker.ResponseObject) => + this.pickerCallback(data, resolve, reject), + ); (builder as unknown as { setZIndex(z: number): void }).setZIndex( Z_INDEX_OVER_FILE_MANAGER_MODAL, @@ -220,37 +231,39 @@ class GoogleDrivePickerService { * Handle picker selection callback */ private async pickerCallback( - data: any, + data: google.picker.ResponseObject, resolve: (files: File[]) => void, reject: (error: Error) => void, ): Promise { - if (data.action === window.google.picker.Action.PICKED) { + const action = data[window.google.picker.Response.ACTION]; + if (action === window.google.picker.Action.PICKED) { try { + const documents = data[window.google.picker.Response.DOCUMENTS] ?? []; const files = await Promise.all( - data[window.google.picker.Response.DOCUMENTS].map( - async (pickedFile: any) => { - const fileId = pickedFile[window.google.picker.Document.ID]; - const res = await window.gapi.client.drive.files.get({ - fileId: fileId, - alt: "media", - }); + documents.map(async (pickedFile) => { + const fileId = pickedFile[window.google.picker.Document.ID]; + const res = await window.gapi.client.drive.files.get({ + fileId: fileId, + alt: "media", + }); - // Convert response body to File object - const file = new File( - [ - new Uint8Array(res.body.length).map((_: any, i: number) => - res.body.charCodeAt(i), - ), - ], - pickedFile.name, - { - type: pickedFile.mimeType, - lastModified: pickedFile.lastModified, - }, - ); - return file; - }, - ), + // Convert response body to File object + const file = new File( + [ + new Uint8Array(res.body.length).map((_, i) => + res.body.charCodeAt(i), + ), + ], + pickedFile[window.google.picker.Document.NAME] ?? "", + { + type: pickedFile[window.google.picker.Document.MIME_TYPE], + lastModified: + pickedFile[window.google.picker.Document.LAST_EDITED_UTC] ?? + Date.now(), + }, + ); + return file; + }), ); resolve(files); @@ -261,7 +274,7 @@ class GoogleDrivePickerService { : new Error("Failed to download files"), ); } - } else if (data.action === window.google.picker.Action.CANCEL) { + } else if (action === window.google.picker.Action.CANCEL) { resolve([]); // User cancelled, return empty array } } @@ -369,7 +382,7 @@ export function getGoogleDriveConfig( * Eliminates duplicated config construction pattern */ export function extractGoogleDriveBackendConfig( - appConfig: any, + appConfig: AppConfig | null, ): BackendGoogleDriveConfig { return { enabled: appConfig?.googleDriveEnabled, diff --git a/frontend/editor/src/core/services/httpErrorHandler.ts b/frontend/editor/src/core/services/httpErrorHandler.ts index 55b0a425f9..dd0c692d97 100644 --- a/frontend/editor/src/core/services/httpErrorHandler.ts +++ b/frontend/editor/src/core/services/httpErrorHandler.ts @@ -6,6 +6,7 @@ import { normalizeAxiosErrorData, } from "@app/services/errorUtils"; import { showSpecialErrorToast } from "@app/services/specialErrorToasts"; +import axios from "axios"; import { handleSaaSError } from "@app/services/saasErrorInterceptor"; import { clampText, @@ -95,15 +96,16 @@ if (typeof window !== "undefined") { * Handles HTTP errors with toast notifications and file error broadcasting * Returns true if the error should be suppressed (deduplicated), false otherwise */ -export async function handleHttpError(error: any): Promise { - const skipAuthRedirect = error?.config?.skipAuthRedirect === true; +export async function handleHttpError(error: unknown): Promise { + const axiosError = axios.isAxiosError(error) ? error : undefined; + const skipAuthRedirect = axiosError?.config?.skipAuthRedirect === true; // Check if this error should skip the global toast (component will handle it) - if (error?.config?.suppressErrorToast === true) { + if (axiosError?.config?.suppressErrorToast === true) { return false; // Don't show global toast, but continue rejection } // Handle 401 authentication errors - const status: number | undefined = error?.response?.status; + const status: number | undefined = axiosError?.response?.status; if (status === 401) { const pathname = window.location.pathname; @@ -119,7 +121,7 @@ export async function handleHttpError(error: any): Promise { if (loginRedirectRecentlyFired()) { console.warn( "[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:", - error?.config?.url, + axiosError?.config?.url, ); return true; } @@ -151,7 +153,7 @@ export async function handleHttpError(error: any): Promise { const { title, body } = extractAxiosErrorMessage(error); // Normalize response data ONCE, reuse for both ID extraction and special-toast matching - const raw = error?.response?.data as any; + const raw = axiosError?.response?.data; let normalized: unknown = raw; try { normalized = await normalizeAxiosErrorData(raw); @@ -170,7 +172,7 @@ export async function handleHttpError(error: any): Promise { } // 2) Generic-vs-special dedupe by endpoint - const url: string | undefined = error?.config?.url; + const url: string | undefined = axiosError?.config?.url; const now = Date.now(); const isSpecial = status === 422 || diff --git a/frontend/editor/src/core/services/httpErrorUtils.ts b/frontend/editor/src/core/services/httpErrorUtils.ts index b3b54e2664..c4898266c3 100644 --- a/frontend/editor/src/core/services/httpErrorUtils.ts +++ b/frontend/editor/src/core/services/httpErrorUtils.ts @@ -25,14 +25,14 @@ function titleForStatus(status?: number): string { return "Request failed"; } -export function extractAxiosErrorMessage(error: any): { +export function extractAxiosErrorMessage(error: unknown): { title: string; body: string; } { if (axios.isAxiosError(error)) { const status = error.response?.status; const _statusText = error.response?.statusText || ""; - let parsed: any = undefined; + let parsed: unknown = undefined; const raw = error.response?.data; if (typeof raw === "string") { try { @@ -44,8 +44,8 @@ export function extractAxiosErrorMessage(error: any): { parsed = raw; } const extractIds = (): string[] | undefined => { - if (Array.isArray(parsed?.errorFileIds)) - return parsed.errorFileIds as string[]; + const errorFileIds = (parsed as { errorFileIds?: unknown })?.errorFileIds; + if (Array.isArray(errorFileIds)) return errorFileIds as string[]; const rawText = typeof raw === "string" ? raw : ""; const uuidMatches = rawText.match( /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, @@ -60,7 +60,8 @@ export function extractAxiosErrorMessage(error: any): { if (!data) return typeof raw === "string" ? raw : ""; const ids = extractIds(); if (ids && ids.length > 0) return `Failed files: ${ids.join(", ")}`; - if (data?.message) return data.message as string; + const message = (data as { message?: unknown })?.message; + if (message) return message as string; if (typeof raw === "string") return raw; try { return JSON.stringify(data); @@ -82,7 +83,8 @@ export function extractAxiosErrorMessage(error: any): { return { title, body: bodyMsg }; } try { - const msg = (error?.message || String(error)) as string; + const msg = ((error as { message?: unknown })?.message || + String(error)) as string; return { title: "Network error", body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg, diff --git a/frontend/editor/src/core/services/pdfWorkerManager.ts b/frontend/editor/src/core/services/pdfWorkerManager.ts index 534c6fc3f1..6da74e1dd1 100644 --- a/frontend/editor/src/core/services/pdfWorkerManager.ts +++ b/frontend/editor/src/core/services/pdfWorkerManager.ts @@ -38,7 +38,7 @@ class PDFWorkerManager { "pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url, ).toString(); - (GlobalWorkerOptions as any).docBaseUrl = undefined; + (GlobalWorkerOptions as { docBaseUrl?: string }).docBaseUrl = undefined; this.isInitialized = true; } } @@ -62,7 +62,7 @@ class PDFWorkerManager { } // Normalize input data to PDF.js format - let pdfData: any; + let pdfData: string | { data: ArrayBuffer | Uint8Array }; if (data instanceof ArrayBuffer || data instanceof Uint8Array) { pdfData = { data }; } else if (typeof data === "string") { diff --git a/frontend/editor/src/core/services/signatureDetectionService.ts b/frontend/editor/src/core/services/signatureDetectionService.ts index 0062380a79..41bd9e267c 100644 --- a/frontend/editor/src/core/services/signatureDetectionService.ts +++ b/frontend/editor/src/core/services/signatureDetectionService.ts @@ -4,13 +4,6 @@ * without needing to make API calls */ -// PDF.js types (simplified) -declare global { - interface Window { - pdfjsLib?: any; - } -} - export interface SignatureDetectionResult { hasSignatures: boolean; signatureCount?: number; @@ -53,7 +46,7 @@ const detectSignaturesInFile = async ( // Count signature annotations (Type: /Sig) const signatureAnnotations = annotations.filter( - (annotation: any) => + (annotation: { subtype?: string; fieldType?: string }) => annotation.subtype === "Widget" && annotation.fieldType === "Sig", ); @@ -62,7 +55,11 @@ const detectSignaturesInFile = async ( // Also check for document-level signatures in AcroForm const metadata = await pdf.getMetadata(); - if (metadata?.info?.Signature || metadata?.metadata?.has("dc:signature")) { + const info = metadata?.info as { Signature?: unknown } | undefined; + const xmpMetadata = metadata?.metadata as + | { has?: (name: string) => boolean } + | undefined; + if (info?.Signature || xmpMetadata?.has?.("dc:signature")) { totalSignatures = Math.max(totalSignatures, 1); } diff --git a/frontend/editor/src/core/services/signatureStorageService.ts b/frontend/editor/src/core/services/signatureStorageService.ts index 1cc0ac9360..da9bb9332d 100644 --- a/frontend/editor/src/core/services/signatureStorageService.ts +++ b/frontend/editor/src/core/services/signatureStorageService.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import apiClient from "@app/services/apiClient"; import type { SavedSignature } from "@app/types/signature"; import { readResponseHeader } from "@app/services/shareBundleUtils"; @@ -54,14 +55,17 @@ class SignatureStorageService { supportsBackend: true, storageType: "backend", }; - } catch (error: any) { + } catch (error: unknown) { + const status = axios.isAxiosError(error) + ? error.response?.status + : undefined; // Check if it's an HTTP error with status code - if (error?.response?.status === 401 || error?.response?.status === 403) { + if (status === 401 || status === 403) { // Backend exists but needs auth - gracefully fall back to localStorage console.log( "[SignatureStorage] Backend signature API requires authentication, using localStorage", ); - } else if (error?.response?.status === 404) { + } else if (status === 404) { // Endpoint doesn't exist (not running proprietary mode) console.log( "[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage", diff --git a/frontend/editor/src/core/services/specialErrorToasts.ts b/frontend/editor/src/core/services/specialErrorToasts.ts index 6a38a54f53..f2ee343803 100644 --- a/frontend/editor/src/core/services/specialErrorToasts.ts +++ b/frontend/editor/src/core/services/specialErrorToasts.ts @@ -1,3 +1,4 @@ +import i18n from "i18next"; import { alert } from "@app/components/toast"; interface ErrorToastMapping { @@ -42,18 +43,13 @@ export function showSpecialErrorToast( for (const mapping of MAPPINGS) { if (mapping.regex.test(message)) { - // Best-effort translation without hard dependency on i18n config let body = mapping.defaultMessage; - try { - const anyGlobal: any = globalThis as any; - const i18next = anyGlobal?.i18next; - if (i18next && typeof i18next.t === "function") { - body = i18next.t(mapping.i18nKey, { - defaultValue: mapping.defaultMessage, - }); - } - } catch { - /* ignore translation errors */ + // The app bootstraps this shared i18next singleton at startup; guard in + // case a toast fires before that (e.g. tests) so we keep the default copy. + if (i18n.isInitialized) { + body = i18n.t(mapping.i18nKey, { + defaultValue: mapping.defaultMessage, + }); } const title = titleForStatus(options?.status); alert({ diff --git a/frontend/editor/src/core/services/usageAnalyticsService.ts b/frontend/editor/src/core/services/usageAnalyticsService.ts index 8f580c820b..f7ba3dd79e 100644 --- a/frontend/editor/src/core/services/usageAnalyticsService.ts +++ b/frontend/editor/src/core/services/usageAnalyticsService.ts @@ -25,7 +25,7 @@ const usageAnalyticsService = { limit?: number, dataType: "all" | "api" | "ui" = "all", ): Promise { - const params: Record = {}; + const params: Record = {}; if (limit !== undefined) { params.limit = limit; diff --git a/frontend/editor/src/global.d.ts b/frontend/editor/src/global.d.ts index cb6275c471..6b38e511b4 100644 --- a/frontend/editor/src/global.d.ts +++ b/frontend/editor/src/global.d.ts @@ -22,6 +22,7 @@ declare global { __STIRLING_PDF_BASE_URL__?: string; STIRLING_PDF_API_BASE_URL?: string; endpointAvailabilityService?: unknown; + pdfjsLib?: typeof import("pdfjs-dist"); } } diff --git a/frontend/oxlint.config.ts b/frontend/oxlint.config.ts index ac29c9b2bb..314bc3178e 100644 --- a/frontend/oxlint.config.ts +++ b/frontend/oxlint.config.ts @@ -74,15 +74,14 @@ const modernGlobals: OxlintGlobals = { // Folders not yet conformant to the stricter no-explicit-any rule const noExplicitAnyExcludes = [ - "editor/src/core/components/shared/*.{js,mjs,jsx,ts,tsx}", + "editor/src/core/components/shared/FilePickerModal.tsx", "editor/src/core/components/shared/config/configSections/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/tools/addStamp/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/components/tools/automate/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/components/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/contexts/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/contexts/viewer/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/hooks/*.{js,mjs,jsx,ts,tsx}", - "editor/src/core/services/*.{js,mjs,jsx,ts,tsx}", + "editor/src/core/services/pdfProcessingService.ts", + "editor/src/core/services/zipFileService.ts", "editor/src/core/tools/annotate/useAnnotationSelection.ts", "editor/src/core/types/*.{js,mjs,jsx,ts,tsx}", "editor/src/core/utils/*.{js,mjs,jsx,ts,tsx}", From dbd60d47659e6042eee86b11177e319354b4c1f5 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 24 Aug 2026 10:34:12 +0000 Subject: [PATCH 04/10] Replace Prettier with Oxfmt (#7422) # Description of Changes Prettier takes about 10 seconds to run over our frontend folder, but [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) does an (almost) identical job in 0.2 seconds. This PR converts our Prettier integration to an equivalent Oxfmt integration. There's exactly 2 files in the frontend folder that Oxfmt formats differently to Prettier so it'll barely cause any disruption to the source. I've removed the `--check` option from the frontend tool models generator as part of this because we can do the same thing with Task easily enough and Oxfmt isn't directly importable like Prettier since it's a Rust binary instead of a JS library. Originally I was shelling out to Oxfmt on single-file mode to keep it all in memory but it just seemed more likely that there'd be config mismatches between that script and the task so I think it's better this way. --- .editorconfig | 15 +- .taskfiles/frontend.yml | 9 +- frontend/.oxfmtrc.json | 33 ++ frontend/.prettierignore | 30 -- frontend/.prettierrc | 6 - frontend/README.md | 2 +- .../scripts/generate-tool-api-types.mts | 62 +-- .../users/UsersDirectory.stories.tsx | 29 +- .../shared/ChangeUserPasswordModal.tsx | 2 +- frontend/package-lock.json | 387 +++++++++++++++++- frontend/package.json | 2 +- frontend/stylelint.config.mjs | 2 +- 12 files changed, 453 insertions(+), 126 deletions(-) create mode 100644 frontend/.oxfmtrc.json delete mode 100644 frontend/.prettierignore delete mode 100644 frontend/.prettierrc diff --git a/.editorconfig b/.editorconfig index 665a74a09a..e6bda814c1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,26 +22,15 @@ indent_size = 4 [*.html] indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false -[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx,*.mts}] indent_size = 2 [*.css] -# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. indent_size = 2 [*.{yml,yaml}] -# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices. -indent_size = 2 -insert_final_newline = false -trim_trailing_whitespace = false - -[*.json] -# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting. indent_size = 2 -[*.jsonc] -# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces. +[*.{json,jsonc}] indent_size = 2 diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index f5325c9ed7..844306824d 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -375,13 +375,13 @@ tasks: desc: "Auto-fix code formatting" deps: [install] cmds: - - npx prettier --write . + - npx oxfmt --write . format:check: desc: "Check code formatting" deps: [install] cmds: - - npx prettier --check . + - npx oxfmt --check . fix: desc: "Auto-fix lint and format" @@ -554,6 +554,7 @@ tasks: deps: [install, ":backend:swagger"] cmds: - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts + - task: format sources: - editor/scripts/generate-tool-api-types.mts - ../SwaggerDoc.json @@ -563,9 +564,9 @@ tasks: tool-models:check: desc: "Fail if committed tool API types are out of date" - deps: [install, ":backend:swagger"] cmds: - - npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --io-output editor/src/core/types/toolIO.ts --check + - task: tool-models + - git diff --exit-code -- editor/src/core/types/toolApiTypes.ts editor/src/core/types/toolIO.ts licenses:generate: desc: "Generate frontend license report" diff --git a/frontend/.oxfmtrc.json b/frontend/.oxfmtrc.json new file mode 100644 index 0000000000..cb801651b8 --- /dev/null +++ b/frontend/.oxfmtrc.json @@ -0,0 +1,33 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf", + "sortPackageJson": false, + "ignorePatterns": [ + "dist/", + "editor/dist/", + "editor/src-tauri/**/target/", + "editor/src-tauri/gen/", + "node_modules/", + "editor/public/vendor/", + "editor/public/mockServiceWorker.js", + "editor/public/og-metadata.json", + "editor/public/og-metadata.saas.json", + "editor/src/core/data/ogImageMap.json", + "editor/src/portal/generated/docsManifest.json", + "editor/public/pdfjs*/", + "editor/public/js/thirdParty/", + "editor/public/css/cookieconsent.css", + "storybook-static/", + "playwright-report/", + "editor/playwright-report/", + "test-results/", + "editor/test-results/", + "*.min.*", + "*.md", + "*.wxs", + "*.toml", + "editor/src/output.css" + ] +} diff --git a/frontend/.prettierignore b/frontend/.prettierignore deleted file mode 100644 index fa8ab6c502..0000000000 --- a/frontend/.prettierignore +++ /dev/null @@ -1,30 +0,0 @@ -dist/ -editor/dist/ -# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier). -# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each -# have their own Cargo workspace under src-tauri/. -editor/src-tauri/**/target/ -editor/src-tauri/gen/ -node_modules/ -editor/public/vendor/ -# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted. -editor/public/mockServiceWorker.js -# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim. -editor/public/og-metadata.json -editor/public/og-metadata.saas.json -editor/src/core/data/ogImageMap.json -# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim. -editor/src/portal/generated/docsManifest.json -editor/public/pdfjs*/ -editor/public/js/thirdParty/ -editor/public/css/cookieconsent.css -# Build / test artifacts that may exist locally even though they're gitignored -storybook-static/ -playwright-report/ -editor/playwright-report/ -test-results/ -editor/test-results/ -*.min.* -*.md -*.wxs -editor/src/output.css diff --git a/frontend/.prettierrc b/frontend/.prettierrc deleted file mode 100644 index 58bc875631..0000000000 --- a/frontend/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "printWidth": 80, - "tabWidth": 2, - "useTabs": false, - "endOfLine": "lf" -} diff --git a/frontend/README.md b/frontend/README.md index ea759538e6..a4945ffae1 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,7 +18,7 @@ For desktop app development, see the [Tauri](#tauri) section below. `frontend/` is a workspace containing one or more apps. Today it holds the PDF editor under `frontend/editor/`; new apps (the developer portal, etc.) will sit alongside it as siblings. Shared tooling — `package.json`, `node_modules`, -`.storybook/`, oxlint, Prettier — lives at `frontend/` so every app installs +`.storybook/`, oxlint, oxfmt — lives at `frontend/` so every app installs once and lints with the same config. ## Environment Variables diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index 38eb7fe7b6..23daa1dd76 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -10,7 +10,6 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { parseArgs } from "node:util"; import { compile, type JSONSchema } from "json-schema-to-typescript"; -import * as prettier from "prettier"; // The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/` // is absent from the spec, so it cannot appear here. Extend this list when other @@ -227,11 +226,7 @@ function collectToolIO( return { table, dropped }; } -async function renderToolIO( - spec: Json, - table: Record, - outputPath: string, -): Promise { +function renderToolIO(spec: Json, table: Record): string { if (Object.keys(table).length === 0) { throw new Error( `No ${IO_EXTENSION} declarations in the spec. The backend publishes these from @ToolIO; regenerate with 'task backend:swagger'.`, @@ -312,33 +307,12 @@ export function toolIOFor( } `; - const prettierConfig = await prettier.resolveConfig(outputPath); - return prettier.format(body, { ...prettierConfig, parser: "typescript" }); + return body; } -/** In check mode, fail when the committed file is out of date. */ -function writeOrCheck( - outputPath: string, - formatted: string, - check: boolean, - task: string, -): void { - if (check) { - let current = ""; - try { - current = readFileSync(outputPath, "utf-8"); - } catch { - // Missing file counts as out of date. - } - if (current !== formatted) { - throw new Error( - `${outputPath} is out of date. Run '${task}' and commit the result.`, - ); - } - return; - } +function writeOutput(outputPath: string, contents: string): void { mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, formatted, "utf-8"); + writeFileSync(outputPath, contents, "utf-8"); } async function main(): Promise { @@ -347,12 +321,11 @@ async function main(): Promise { spec: { type: "string" }, output: { type: "string" }, "io-output": { type: "string" }, - check: { type: "boolean", default: false }, }, }); if (!values.spec || !values.output || !values["io-output"]) { throw new Error( - "Usage: generate-tool-api-types.mts --spec --output --io-output [--check]", + "Usage: generate-tool-api-types.mts --spec --output --io-output ", ); } const specPath = resolve(values.spec); @@ -478,14 +451,9 @@ async function main(): Promise { `Dropped ${dropped.length} @ToolIO declaration(s) on paths that are not tool endpoints. Add the namespace to ALLOWED_PATH_PREFIXES if a pipeline can contain these steps:\n ${dropped.join("\n ")}`, ); } - writeOrCheck( - ioOutputPath, - await renderToolIO(spec, ioDeclarations, ioOutputPath), - values.check ?? false, - "task frontend:tool-models", - ); + writeOutput(ioOutputPath, renderToolIO(spec, ioDeclarations)); console.log( - `${values.check ? "Up to date" : "Generated"}: ${Object.keys(ioDeclarations).length} tool I/O declarations.`, + `Generated ${Object.keys(ioDeclarations).length} tool I/O declarations.`, ); // Transitively inline every referenced component into `definitions`, rewriting its refs too. @@ -508,7 +476,6 @@ async function main(): Promise { definitions, fileFieldsByClass, outputPath, - values.check ?? false, skipped, ); } @@ -518,7 +485,6 @@ async function compileAndWrite( definitions: Record, fileFieldsByClass: Record, outputPath: string, - check: boolean, skipped: string[], ): Promise { // json-schema-to-typescript only emits a named, exported interface per schema @@ -597,17 +563,9 @@ async function compileAndWrite( ].join("\n"); const body = `${FILE_HEADER}\n\n${models}\n\n${footer}\n`; - const prettierConfig = await prettier.resolveConfig(outputPath); - const formatted = await prettier.format(body, { - ...prettierConfig, - parser: "typescript", - }); - - writeOrCheck(outputPath, formatted, check, "task frontend:tool-models"); - console.log( - `${check ? "Up to date" : "Generated"}: ${tools.length} tool endpoints.`, - ); - if (!check && skipped.length > 0) { + writeOutput(outputPath, body); + console.log(`Generated ${tools.length} tool endpoints.`); + if (skipped.length > 0) { console.log( `Skipped ${skipped.length} POST endpoint(s) with no request body: ${skipped.join(", ")}`, ); diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx index 2925958efd..75da086806 100644 --- a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -215,22 +215,19 @@ const BIG_MEMBERS: Member[] = [ isSelf: true, portalAccess: "admin", }, - ...Array.from( - { length: 11 }, - (_, i): Member => ({ - id: `big-${i}`, - name: `Teammate ${i + 1}`, - email: `teammate${i + 1}@acme.com`, - role: i === 0 ? "team_owner" : "member", - status: "active", - lastActive: `${i + 1}h ago`, - username: `tm${i + 1}`, - teamId: 9, - teamName: "Platform", - teamLead: i === 0, - portalAccess: i === 0 ? "role" : "none", - }), - ), + ...Array.from({ length: 11 }, (_, i): Member => ({ + id: `big-${i}`, + name: `Teammate ${i + 1}`, + email: `teammate${i + 1}@acme.com`, + role: i === 0 ? "team_owner" : "member", + status: "active", + lastActive: `${i + 1}h ago`, + username: `tm${i + 1}`, + teamId: 9, + teamName: "Platform", + teamLead: i === 0, + portalAccess: i === 0 ? "role" : "none", + })), ]; const TEAMS: Team[] = [ diff --git a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx index 89d648fcc5..a482b3fab1 100644 --- a/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx +++ b/frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.tsx @@ -38,7 +38,7 @@ function generateSecurePassword() { const uint8Array = new Uint8Array(length); window.crypto.getRandomValues(uint8Array); // To avoid modulo bias, discard values >= 256 - (256 % charsetLength) - for (let i = 0; password.length < length; ) { + for (let i = 0; password.length < length;) { const randomByte = uint8Array[i]; i++; if (randomByte >= Math.floor(256 / charsetLength) * charsetLength) { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9de0a5bf6a..6ade3d2a28 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -121,12 +121,12 @@ "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", + "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", "storybook": "^9.1.20", @@ -2905,6 +2905,329 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.62.0.tgz", + "integrity": "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.62.0.tgz", + "integrity": "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.62.0.tgz", + "integrity": "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.62.0.tgz", + "integrity": "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.62.0.tgz", + "integrity": "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.62.0.tgz", + "integrity": "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.62.0.tgz", + "integrity": "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.62.0.tgz", + "integrity": "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.62.0.tgz", + "integrity": "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.62.0.tgz", + "integrity": "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.62.0.tgz", + "integrity": "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.62.0.tgz", + "integrity": "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.62.0.tgz", + "integrity": "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.62.0.tgz", + "integrity": "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.62.0.tgz", + "integrity": "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.62.0.tgz", + "integrity": "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.62.0.tgz", + "integrity": "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.62.0.tgz", + "integrity": "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.62.0.tgz", + "integrity": "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxlint/binding-android-arm-eabi": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", @@ -12611,6 +12934,68 @@ "dev": true, "license": "MIT" }, + "node_modules/oxfmt": { + "version": "0.62.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.62.0.tgz", + "integrity": "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.62.0", + "@oxfmt/binding-android-arm64": "0.62.0", + "@oxfmt/binding-darwin-arm64": "0.62.0", + "@oxfmt/binding-darwin-x64": "0.62.0", + "@oxfmt/binding-freebsd-x64": "0.62.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", + "@oxfmt/binding-linux-arm64-gnu": "0.62.0", + "@oxfmt/binding-linux-arm64-musl": "0.62.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", + "@oxfmt/binding-linux-riscv64-musl": "0.62.0", + "@oxfmt/binding-linux-s390x-gnu": "0.62.0", + "@oxfmt/binding-linux-x64-gnu": "0.62.0", + "@oxfmt/binding-linux-x64-musl": "0.62.0", + "@oxfmt/binding-openharmony-arm64": "0.62.0", + "@oxfmt/binding-win32-arm64-msvc": "0.62.0", + "@oxfmt/binding-win32-ia32-msvc": "0.62.0", + "@oxfmt/binding-win32-x64-msvc": "0.62.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/oxfmt/node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/oxlint": { "version": "1.77.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6bbb3e9137..71a71661e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -143,12 +143,12 @@ "license-checker": "^25.0.1", "msw": "^2.14.6", "msw-storybook-addon": "^2.0.7", + "oxfmt": "^0.62.0", "oxlint": "^1.77.0", "postcss": "^8.5.12", "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", - "prettier": "^3.8.1", "puppeteer": "^24.25.0", "rollup-plugin-visualizer": "^7.0.1", "storybook": "^9.1.20", diff --git a/frontend/stylelint.config.mjs b/frontend/stylelint.config.mjs index 2781e78516..79f3108c9c 100644 --- a/frontend/stylelint.config.mjs +++ b/frontend/stylelint.config.mjs @@ -1,4 +1,4 @@ -// Deliberately minimal: Prettier owns formatting and `task frontend:lint:colors` +// Deliberately minimal: oxfmt owns formatting and `task frontend:lint:colors` // owns the theme tokens, so this only carries rules that catch real bugs. export default { ignoreFiles: [ From 8e7501aec12e45a7c57006641a8e7b4c05d507d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:38:54 +0000 Subject: [PATCH 05/10] build(deps): bump org.snakeyaml:snakeyaml-engine from 3.0.1 to 3.1.1 (#7655) Bumps org.snakeyaml:snakeyaml-engine from 3.0.1 to 3.1.1. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.snakeyaml:snakeyaml-engine&package-manager=gradle&previous-version=3.0.1&new-version=3.1.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/common/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/build.gradle b/app/common/build.gradle index bb956504cc..8af68bcb76 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -18,7 +18,7 @@ dependencies { api "org.apache.pdfbox:preflight:$pdfboxVersion" api 'com.github.junrar:junrar:8.0.0' // RAR archive support for CBR files api 'jakarta.servlet:jakarta.servlet-api:6.1.0' - api 'org.snakeyaml:snakeyaml-engine:3.0.1' + api 'org.snakeyaml:snakeyaml-engine:3.1.1' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3" // Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage) api 'org.simplejavamail:simple-java-mail:9.3.2' From e50c3de0a9518ed5d4d7c90b91318cc82d6b7c6a Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:16:34 +0000 Subject: [PATCH 06/10] Run classification locally first and only escalate an unsure verdict to the AI (#7580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #7574 — this is the classification half, which is independent of the editor-source work and can land on its own. ## What this does - **Runs the local heuristic first and only escalates an unsure verdict to the AI.** A high-confidence local answer stands; anything less (or a file the heuristic hasn't reached yet) goes to the engine. A wrong label costs more than an engine call, so the bar is deliberately strict. - **Makes `classify` an authorable pipeline task**, so it can be used as a step like any other tool, and skips files that are already classified. - **Leaves the seeded Classification policy unowned** rather than naming a `system` placeholder that was never a real user; existing seeds are repaired on boot. ## Review feedback applied From @jbrunton96 on #7574: - **The generic runner no longer names classification.** Everything classification-specific moved into `proprietary/data/classificationPolicy.ts`, and `usePolicyAutoRun` now asks capability questions instead: `policyRewritesDocument`, `policyDeliversOutputFiles`, `policyRequiresAiEngine`, `shouldDispatchToAi`. There is no `id === "classification"` left in the runner. - **Ordering is no longer a name in the runner.** `pinClassificationLast` is gone; the runner sorts annotating policies after rewriting ones. The constraint is real: an annotating policy is non-blocking, so a rewriting one running after it forks from the pre-annotation version and drops the labels. To be straight about what this is and isn't - see "Still open" below - `policyRewritesDocument` is still keyed on the category id, not on a property each policy declares. The check moved out of the runner; it did not stop being a check on one id. - **Confidence is typed.** New `ClassificationConfidence` union in `core/types/fileContext.ts`, reused by `fileStorage`, `HeuristicConfidence`, and the trusted-verdict constant instead of being respelled at each site. - **Comments trimmed** to the repo's 2-line guideline, and a stale seeder javadoc that still claimed an internal-user owner was corrected. ## Still open, deliberately `classificationPolicy.ts` answers its capability questions with `categoryId === "classification"`. That is the same check relocated, not removed, and the module doc now says so outright. Deliberate, for two reasons: - **The concept it would be declared against is going away.** Policies are becoming pipelines with labels behind a separate enforcement layer, which removes the category the flag would live on. A capability system built on `categoryId` today gets migrated twice. - **Classification is genuinely privileged, not accidentally special.** It is the only policy with a browser-side implementation, so it can answer without the server. That is a product decision, and a local-only mode for set scenarios is planned - the flag for it should be designed with that feature, not guessed at now. The end state for the rest: an in-place output mode retires the ordering rule and `policyDeliversOutputFiles`, and a run result that can carry findings as well as files retires the remainder. Both touch the import path, which is the most delicate code in `usePolicyAutoRun` - not something to bolt on to a PR that has already been split once. Nothing is broken by leaving it. A user-built classify pipeline still gets its labels: the generic import path reads them off the returned PDF. It versions the file instead of labelling in place, and it misses the local-heuristic shortcut, so it always bills the engine. ## Testing - `classificationPolicy.test.ts` — 12 cases covering each capability and the escalation rule - Full frontend `proprietary` project: 39 files / 442 tests - `:proprietary:test` for `DefaultClassificationPolicySeederTest` + `ClassifyLabelControllerTest` - `tsc --noEmit` on core, proprietary, portal, saas, desktop, cloud --------- Co-authored-by: James Brunton --- .../api/ClassifyLabelController.java | 49 +++- .../proprietary/policy/model/Policy.java | 5 + .../DefaultClassificationPolicySeeder.java | 41 ++- .../api/ClassifyLabelControllerTest.java | 49 +++- ...DefaultClassificationPolicySeederTest.java | 29 +- .../public/locales/en-US/translation.toml | 5 + frontend/editor/public/og-metadata.json | 6 + frontend/editor/public/og-metadata.saas.json | 6 + .../scripts/generate-tool-api-types.mts | 10 +- .../data/classifyIsAPipelineTask.test.tsx | 76 +++++ .../editor/src/core/data/toolsTaxonomy.ts | 6 + .../core/data/useTranslatedToolRegistry.tsx | 25 ++ .../tools/classify/useClassifyOperation.ts | 52 ++++ .../editor/src/core/services/fileStorage.ts | 6 + frontend/editor/src/core/types/fileContext.ts | 8 + .../editor/src/core/types/toolApiTypes.ts | 6 + frontend/editor/src/core/types/toolIO.ts | 5 + frontend/editor/src/core/types/toolId.ts | 1 + frontend/editor/src/core/utils/toolSearch.ts | 6 +- .../policies/useClientSideClassification.ts | 75 +++-- .../policies/usePolicyAutoRun.batch.test.tsx | 20 +- .../policies/usePolicyAutoRun.race.test.tsx | 4 +- .../components/policies/usePolicyAutoRun.ts | 268 +++++++----------- .../proprietary/components/viewer/Viewer.tsx | 2 +- .../data/classificationPolicy.test.ts | 91 ++++++ .../proprietary/data/classificationPolicy.ts | 69 +++++ .../proprietary/data/policyCategories.test.ts | 41 --- .../src/proprietary/data/policyCategories.ts | 21 -- .../src/proprietary/hooks/usePolicies.ts | 8 +- .../proprietary/hooks/usePolicyFileBadges.ts | 2 +- .../proprietary/services/heuristic/types.ts | 4 +- 31 files changed, 707 insertions(+), 289 deletions(-) create mode 100644 frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx create mode 100644 frontend/editor/src/core/hooks/tools/classify/useClassifyOperation.ts create mode 100644 frontend/editor/src/proprietary/data/classificationPolicy.test.ts create mode 100644 frontend/editor/src/proprietary/data/classificationPolicy.ts delete mode 100644 frontend/editor/src/proprietary/data/policyCategories.test.ts delete mode 100644 frontend/editor/src/proprietary/data/policyCategories.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java index 798de71df9..dc422433f0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ClassifyLabelController.java @@ -9,6 +9,7 @@ import java.util.Map; import java.util.Set; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -20,12 +21,13 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import io.github.pixee.security.Filenames; -import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.tool.ToolFormat; +import stirling.software.common.model.tool.ToolIO; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.service.PdfMetadataService; import stirling.software.common.service.UserServiceInterface; @@ -48,11 +50,13 @@ import tools.jackson.databind.node.ObjectNode; *

Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI * engine to classify the document against the built-in label set, and stores the engine's JSON * answer — minus the transport-only {@code outcome} field — in the custom Info-dictionary key - * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. Not intended for direct - * client use. + * {@link PdfMetadataService#CLASSIFICATION_KEY}. Returns the labelled PDF. + * + *

Published in the API spec rather than hidden, so the tool-model generator emits it and a + * pipeline can name it as a step like any other tool. Classification is a thing a pipeline does, + * not a thing only the Classification policy may do. */ @Slf4j -@Hidden @RestController @RequestMapping("/api/v1/ai/tools") @Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.") @@ -99,19 +103,31 @@ public class ClassifyLabelController { } @PostMapping(value = "/classify-and-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + // PDF in, the same PDF out with a verdict on it, so a chain can be checked across this step. + @ToolIO(accepts = ToolFormat.PDF, produces = ToolFormat.PDF) @Operation( summary = "Classify a PDF and label its metadata", description = "Reads the first two and last two pages, classifies the document via the AI" + " engine, and stores the result in the StirlingPDFClassification" - + " metadata field. Dispatched by the Classification policy; not" - + " intended for direct client use.") + + " metadata field. A document that already carries a verdict is" + + " passed through untouched unless reclassify=true.") public ResponseEntity classifyAndLabel( - @RequestParam("fileInput") MultipartFile fileInput) throws IOException { + @RequestParam("fileInput") MultipartFile fileInput, + @RequestParam(value = "reclassify", defaultValue = "false") boolean reclassify) + throws IOException { aiFeatureGate.requireClassify(); try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) { String fileName = safeFileName(fileInput.getOriginalFilename()); + if (!reclassify && isClassified(document)) { + // Classifying twice costs a second engine call and charges for it, and a document + // that already carries a verdict has nothing new to learn. A pipeline can run this + // step over a mixed batch without paying for the ones already done. + log.debug("[classify-and-label] {} already classified; passing through", fileName); + return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager); + } + List allowed = resolveAllowedLabels(); if (allowed.isEmpty()) { // No vocabulary to classify against: pass the file through unlabelled rather than @@ -135,6 +151,25 @@ public class ClassifyLabelController { } } + /** + * Whether a verdict is already on the document. + * + *

This only reads back what a previous run of this step wrote. It is not a statement that + * the verdict is trustworthy: the key is ordinary PDF metadata that whoever supplied the file + * can set. Skipping the engine on the strength of it is safe because the cost of being wrong is + * a missing re-classification, not a wrong decision. Anything that makes a SECURITY decision + * from this field - routing a document somewhere on the strength of its label, say - must + * classify with {@code reclassify=true} rather than trust what arrived. + */ + private static boolean isClassified(PDDocument document) { + PDDocumentInformation info = document.getDocumentInformation(); + if (info == null) { + return false; + } + String existing = info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY); + return existing != null && !existing.isBlank(); + } + private List extractWindow(PDDocument document) throws IOException { List pages = new ArrayList<>(); for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index a3d43b712d..14b1eb325c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -85,6 +85,11 @@ public record Policy( return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); } + /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ + public Policy withOwner(String newOwner) { + return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId); + } + /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 63ebae833a..9b347366bc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -22,8 +22,8 @@ import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; /** - * Seeds an enabled Classification policy per team so classification is on by default. Idempotent; - * skips the internal team. + * Seeds an enabled Classification policy per team; idempotent, skips the internal team. Left + * unowned: nobody created it, and an owner here would have to name a real user. */ @Slf4j @Component @@ -34,6 +34,11 @@ public class DefaultClassificationPolicySeeder { private static final String CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label"; private static final String POLICY_NAME = "Classification Policy"; + /** + * Pre-existing seeds used this placeholder, which was never a user; see {@link #repairOwner}. + */ + private static final String LEGACY_OWNER = "system"; + private final PolicyStore policyStore; private final TeamRepository teamRepository; @@ -46,9 +51,8 @@ public class DefaultClassificationPolicySeeder { .ifPresent(team -> seedIfMissing(team.getId(), team.getName())); } - // Any team created at runtime (admin-created, SaaS sign-ups). Seeds inside the team's own - // transaction: rollback still leaves no policy behind, and the store's pessimistic lock needs a - // live transaction, which AFTER_COMMIT cannot offer. + // Seeds inside the new team's own transaction: rollback leaves no policy behind, and the + // store's pessimistic lock needs a live transaction, which AFTER_COMMIT cannot offer. @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void onTeamCreated(TeamCreatedEvent event) { seedIfMissing(event.teamId(), event.teamName()); @@ -58,16 +62,33 @@ public class DefaultClassificationPolicySeeder { if (teamId == null || TeamService.INTERNAL_TEAM_NAME.equals(teamName)) { return; } - boolean alreadySeeded = + Policy existing = policyStore.findByTeam(teamId).stream() - .anyMatch(DefaultClassificationPolicySeeder::isClassification); - if (alreadySeeded) { + .filter(DefaultClassificationPolicySeeder::isClassification) + .findFirst() + .orElse(null); + if (existing != null) { + repairOwner(existing); return; } policyStore.save(defaultPolicy(teamId)); log.info("Seeded default Classification policy for team {}", teamId); } + /** + * Clear an owner seeded as a placeholder name. An owner someone deliberately set is left alone. + */ + private void repairOwner(Policy policy) { + if (!LEGACY_OWNER.equals(policy.owner())) { + return; + } + policyStore.save(policy.withOwner(null)); + log.info( + "Cleared placeholder owner '{}' on Classification policy {}", + LEGACY_OWNER, + policy.id()); + } + private static boolean isClassification(Policy policy) { return policy.output() != null && CATEGORY.equals(policy.output().options().get("categoryId")); @@ -85,7 +106,9 @@ public class DefaultClassificationPolicySeeder { return new Policy( null, POLICY_NAME, - "system", + // Nobody created this - it is seeded. A name here would have to be a real user, and + // every consumer of owner already handles its absence. + null, true, List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java index 74749289a7..637f05b284 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ClassifyLabelControllerTest.java @@ -14,6 +14,7 @@ import static org.mockito.Mockito.when; import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentInformation; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; @@ -76,7 +77,7 @@ class ClassifyLabelControllerTest { .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"invoice\"]}"); try { - controller.classifyAndLabel(file); + controller.classifyAndLabel(file, false); } catch (Exception ignored) { // WebResponseUtils.pdfDocToWebResponse needs a real temp file; the engine call and // metadata write we assert on have already happened by the time it runs. @@ -89,6 +90,52 @@ class ClassifyLabelControllerTest { return objectMapper.readTree(body.getValue()); } + /** Stubs a document that already carries a verdict, as a second run over a batch would see. */ + private MultipartFile alreadyClassifiedDocument() throws Exception { + PDDocument document = mock(PDDocument.class); + PDDocumentInformation info = mock(PDDocumentInformation.class); + when(document.getDocumentInformation()).thenReturn(info); + when(info.getCustomMetadataValue(PdfMetadataService.CLASSIFICATION_KEY)) + .thenReturn("{\"labels\":[\"invoice\"]}"); + MultipartFile file = mock(MultipartFile.class); + when(file.getOriginalFilename()).thenReturn("invoice.pdf"); + when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document); + return file; + } + + @Test + void classifyAndLabel_skipsADocumentThatAlreadyCarriesAVerdict() throws Exception { + withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); + MultipartFile file = alreadyClassifiedDocument(); + + try { + controller.classifyAndLabel(file, false); + } catch (Exception ignored) { + // The response needs a real temp file; the decision under test happens before it. + } + + // No second engine call, and no charge for one: re-classifying buys the same answer twice. + verify(aiEngineClient, never()).post(anyString(), anyString(), any()); + verify(pdfMetadataService, never()).setClassificationMetadata(any(), anyString()); + } + + @Test + void classifyAndLabel_reclassifiesWhenAskedTo() throws Exception { + withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); + MultipartFile file = alreadyClassifiedDocument(); + when(pdfContentExtractor.extractPageTextRaw(any(), eq(1))).thenReturn("Invoice total"); + when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull())) + .thenReturn("{\"outcome\":\"classification\",\"labels\":[\"receipt\"]}"); + + try { + controller.classifyAndLabel(file, true); + } catch (Exception ignored) { + // As above. + } + + verify(aiEngineClient).post(eq("/api/v1/documents/classify"), anyString(), isNull()); + } + @Test void classifyAndLabel_writesClassificationWithoutOutcome() throws Exception { withLabels(List.of(new ClassificationLabel("invoice", "Invoice", null))); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index 49159e7d6e..f6e82bd011 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -36,10 +36,14 @@ class DefaultClassificationPolicySeederTest { } private static Policy classificationPolicy(Long teamId) { + return classificationPolicy(teamId, null); + } + + private static Policy classificationPolicy(Long teamId, String owner) { return new Policy( "p1", "Classification Policy", - "system", + owner, true, List.of(), List.of(), @@ -77,6 +81,29 @@ class DefaultClassificationPolicySeederTest { verify(policyStore, never()).save(any()); } + @Test + void clearsAPlaceholderOwnerSeededBeforeOwnersHadToBeReal() { + when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "system"))); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + // "system" was never a user row, and a step dispatch authenticates as the owner. Absence + // is handled everywhere; a placeholder name is not. + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + assertThat(saved.getValue().owner()).isNull(); + assertThat(saved.getValue().id()).isEqualTo("p1"); + } + + @Test + void leavesADeliberatelyChosenOwnerAlone() { + when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L, "alice"))); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + verify(policyStore, never()).save(any()); + } + @Test void doesNotSeedForTheInternalTeam() { seeder().onTeamCreated(new TeamCreatedEvent(2L, "Internal")); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d2eda294b2..88814dcf08 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4592,6 +4592,10 @@ desc = "Change document restrictions and permissions" tags = "permissions,restrictions,rights,access control,allow,deny,printing,copying,editing,modify permissions,security settings,user rights" title = "Change Permissions" +[home.classify] +desc = "Identify what kind of document this is and tag it." +title = "Classify" + [home.compare] desc = "Compares and shows the differences between 2 PDF Documents" tags = "difference,compare,diff,compare PDFs,compare documents,find differences,show differences,changes,what changed,track changes,revisions,version compare,side by side,contrast,delta" @@ -10841,6 +10845,7 @@ searchPlaceholder = "Search tools..." [toolPicker.subcategories] advancedFormatting = "Advanced Formatting" +ai = "AI" automation = "Automation" developerTools = "Developer Tools" documentReview = "Document Review" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index f9733bd83f..b1eadb3bce 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -195,6 +195,11 @@ "title": "Compress - Stirling PDF", "description": "Compress PDFs to reduce their file size." }, + "classify": { + "image": "/og_images/home.png", + "title": "Classify - Stirling PDF", + "description": "Identify what kind of document this is and tag it." + }, "extractPages": { "image": "/og_images/extract-pages.png", "title": "Extract Pages - Stirling PDF", @@ -575,6 +580,7 @@ "/remove-cert-sign": "removeCertSign", "/unlock-p-d-f-forms": "unlockPDFForms", "/compress": "compress", + "/classify": "classify", "/extract-pages": "extractPages", "/reorganize-pages": "reorganizePages", "/extract-images": "extractImages", diff --git a/frontend/editor/public/og-metadata.saas.json b/frontend/editor/public/og-metadata.saas.json index be2e7a3e92..14e30f827c 100644 --- a/frontend/editor/public/og-metadata.saas.json +++ b/frontend/editor/public/og-metadata.saas.json @@ -196,6 +196,11 @@ "title": "Compress - Stirling PDF", "description": "Compress PDFs to reduce their file size." }, + "classify": { + "image": "/og_images/home.png", + "title": "Classify - Stirling PDF", + "description": "Identify what kind of document this is and tag it." + }, "extractPages": { "image": "/og_images/extract-pages.png", "title": "Extract Pages - Stirling PDF", @@ -588,6 +593,7 @@ "/remove-cert-sign": "removeCertSign", "/unlock-p-d-f-forms": "unlockPDFForms", "/compress": "compress", + "/classify": "classify", "/extract-pages": "extractPages", "/reorganize-pages": "reorganizePages", "/extract-images": "extractImages", diff --git a/frontend/editor/scripts/generate-tool-api-types.mts b/frontend/editor/scripts/generate-tool-api-types.mts index 23daa1dd76..ec38dc37d7 100644 --- a/frontend/editor/scripts/generate-tool-api-types.mts +++ b/frontend/editor/scripts/generate-tool-api-types.mts @@ -11,13 +11,8 @@ import { dirname, resolve } from "node:path"; import { parseArgs } from "node:util"; import { compile, type JSONSchema } from "json-schema-to-typescript"; -// The API namespaces whose endpoints a pipeline can reference. `/api/v1/ai/tools/` -// is absent from the spec, so it cannot appear here. Extend this list when other -// namespaces become tools. -// -// `/api/v1/filter/` and `/api/v1/integration/` are included even though neither is a -// user-facing tool: a stored pipeline can contain one, and ToolEndpoint keys the I/O -// table, so leaving them out would stop a chain being checked past such a step. +// Endpoints a pipeline can reference. filter/integration are not user-facing tools but a stored +// pipeline can contain one; the AI namespace is admitted one endpoint at a time, not wholesale. const ALLOWED_PATH_PREFIXES = [ "/api/v1/general/", "/api/v1/misc/", @@ -25,6 +20,7 @@ const ALLOWED_PATH_PREFIXES = [ "/api/v1/convert/", "/api/v1/filter/", "/api/v1/integration/", + "/api/v1/ai/tools/classify-and-label", ]; // File plumbing, not user parameters: `fileInput` and `file` are the uploaded primary document diff --git a/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx new file mode 100644 index 0000000000..f5281f80ae --- /dev/null +++ b/frontend/editor/src/core/data/classifyIsAPipelineTask.test.tsx @@ -0,0 +1,76 @@ +/** + * Classification is a thing a pipeline can do, not a thing only the Classification policy may do. + * + * The chain that used to stop it: `getExecutableTools` drops any tool whose endpoint is not a + * member of the generated `ToolEndpoint` union; that union comes from the OpenAPI spec, gated by + * the generator's namespace allowlist; and the classify controller was `@Hidden`, so it never + * reached the spec at all. These tests pin each link, because any one of them silently removes the + * step from the builder's picker rather than failing loudly. + */ +import { describe, expect, test, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry"; +import { getExecutableTools } from "@app/hooks/tools/shared/toolAutomation"; +import { isToolEndpoint } from "@app/hooks/tools/shared/toolApiMapping"; +import { TOOL_IO } from "@app/types/toolIO"; +import { filterToolRegistryByQuery } from "@app/utils/toolSearch"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { changeLanguage: vi.fn(), language: "en-US" }, + }), + Trans: ({ children }: { children?: unknown }) => children, +})); + +const CLASSIFY_ENDPOINT = "/api/v1/ai/tools/classify-and-label"; + +describe("classify as a pipeline task", () => { + test("the classify endpoint is a generated ToolEndpoint", () => { + // Fails if the controller goes back to @Hidden, or the generator's allowlist drops the + // /api/v1/ai/tools/ namespace, or nobody regenerated after either. + expect(isToolEndpoint(CLASSIFY_ENDPOINT)).toBe(true); + }); + + test("the builder offers it as a step", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const executable = getExecutableTools(result.current.regularTools); + const classify = executable.find((tool) => tool.toolId === "classify"); + + expect(classify).toBeDefined(); + expect(classify?.endpoint).toBe(CLASSIFY_ENDPOINT); + }); + + test("it declares PDF in, PDF out, so a chain can be checked across it", () => { + // Without this the builder shows "Can't check what this step accepts" and validation stops + // dead at the step - the I/O table is keyed by endpoint and comes from @ToolIO in the spec. + expect(TOOL_IO[CLASSIFY_ENDPOINT]).toEqual({ + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }); + }); + + test("it is offered to pipelines but kept out of the editor's tool list", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + // There is no interactive classify tool to open - it only means something inside a pipeline. + expect(result.current.regularTools.classify?.hiddenFromToolList).toBe(true); + expect( + filterToolRegistryByQuery(result.current.regularTools, "").some( + (ranked) => ranked.item[0] === "classify", + ), + ).toBe(false); + }); + + test("it does not re-classify by default", () => { + const { result } = renderHook(() => useTranslatedToolCatalog()); + + const config = result.current.regularTools.classify?.operationConfig; + + // The step is idempotent unless asked otherwise: a second run on a classified document + // would be a second engine call, and a second charge, for the same answer. + expect(config?.defaultParameters).toEqual({ reclassify: false }); + }); +}); diff --git a/frontend/editor/src/core/data/toolsTaxonomy.ts b/frontend/editor/src/core/data/toolsTaxonomy.ts index f9c2dde02f..740b8a13ec 100644 --- a/frontend/editor/src/core/data/toolsTaxonomy.ts +++ b/frontend/editor/src/core/data/toolsTaxonomy.ts @@ -29,6 +29,7 @@ import { ProprietaryToolId } from "@app/types/proprietaryToolId"; import { PrototypeToolId } from "@app/types/prototypeToolId"; export enum SubcategoryId { + AI = "ai", SIGNING = "signing", DOCUMENT_SECURITY = "documentSecurity", VERIFICATION = "verification", @@ -71,6 +72,8 @@ export type ToolRegistryEntry = { > | null; // Whether this tool supports automation (defaults to true) supportsAutomate?: boolean; + // Keep out of the editor's tool list: a step only a pipeline runs, with no UI to open. + hiddenFromToolList?: boolean; // Synonyms for search (optional) synonyms?: string[]; // Version status indicator (e.g., "alpha", "beta") @@ -90,6 +93,8 @@ export type ProprietaryToolRegistry = Record< export type PrototypeToolRegistry = Record; export const SUBCATEGORY_ORDER: SubcategoryId[] = [ + // First: AI steps are the ones a user is least likely to know exist. + SubcategoryId.AI, SubcategoryId.SIGNING, SubcategoryId.DOCUMENT_SECURITY, SubcategoryId.VERIFICATION, @@ -104,6 +109,7 @@ export const SUBCATEGORY_ORDER: SubcategoryId[] = [ ]; export const SUBCATEGORY_COLOR_MAP: Record = { + [SubcategoryId.AI]: "var(--category-color-automation)", // Pink [SubcategoryId.SIGNING]: "var(--category-color-signing)", // Green [SubcategoryId.DOCUMENT_SECURITY]: "var(--category-color-security)", // Orange [SubcategoryId.VERIFICATION]: "var(--category-color-verification)", // Orange diff --git a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx index 5ae8075d0f..5e81427d55 100644 --- a/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/editor/src/core/data/useTranslatedToolRegistry.tsx @@ -20,6 +20,7 @@ import { import { adjustContrastOperationConfig } from "@app/hooks/tools/adjustContrast/useAdjustContrastOperation"; import { getSynonyms } from "@app/utils/toolSynonyms"; import { useProprietaryToolRegistry } from "@app/data/useProprietaryToolRegistry"; +import { classifyOperationConfig } from "@app/hooks/tools/classify/useClassifyOperation"; import { compressOperationConfig } from "@app/hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "@app/hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation"; @@ -1338,6 +1339,30 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { synonyms: getSynonyms(t, "compare"), supportsAutomate: false, }, + classify: { + icon: ( + + ), + name: t("home.classify.title", "Classify"), + // No interactive UI: this is a pipeline step, registered so a pipeline can name it. + component: null, + description: t( + "home.classify.desc", + "Identify what kind of document this is and tag it.", + ), + categoryId: ToolCategoryId.ADVANCED_TOOLS, + subcategoryId: SubcategoryId.AI, + maxFiles: -1, + endpoints: ["classify-and-label"], + operationConfig: asRegistryConfig(classifyOperationConfig), + automationSettings: null, + // Pipeline-only: there is no interactive classify tool to open in the editor. + hiddenFromToolList: true, + }, compress: { icon: ( The tool reads a window of the document, asks the AI engine what kind of document it is, and + * writes the verdict into the PDF's metadata. It has no interactive UI - there is nothing for a + * user to set beyond whether to redo work already done - so it exists in the registry purely so a + * pipeline can name it, the same way the Classification policy always could. + */ +const ENDPOINT = "/api/v1/ai/tools/classify-and-label" satisfies ToolEndpoint; +type ClassifyApiParams = ToolApiParams[typeof ENDPOINT]; + +export interface ClassifyParameters { + /** + * Classify again even when the document already carries a verdict. Off by default: re-running + * costs a second engine call, and a document that has been classified has nothing new to say. + */ + reclassify: boolean; +} + +export const defaultParameters: ClassifyParameters = { reclassify: false }; + +export const classifyToApiParams = ( + parameters: ClassifyParameters, +): ClassifyApiParams => ({ reclassify: parameters.reclassify }); + +export const classifyFromApiParams = ( + apiParams: ClassifyApiParams, +): Partial => ({ + reclassify: apiParams.reclassify ?? defaultParameters.reclassify, +}); + +export const buildClassifyFormData = ( + parameters: ClassifyParameters, + file: File, +): FormData => + objectToFormData(classifyToApiParams(parameters), { fileInput: file }); + +export const classifyOperationConfig = defineSingleFileTool({ + buildFormData: buildClassifyFormData, + toApiParams: classifyToApiParams, + fromApiParams: classifyFromApiParams, + operationType: "classify", + endpoint: ENDPOINT, + defaultParameters, +}); diff --git a/frontend/editor/src/core/services/fileStorage.ts b/frontend/editor/src/core/services/fileStorage.ts index 27b8746604..b0cede48cb 100644 --- a/frontend/editor/src/core/services/fileStorage.ts +++ b/frontend/editor/src/core/services/fileStorage.ts @@ -10,6 +10,7 @@ import { StirlingFile, StirlingFileStub, createStirlingFile, + type ClassificationConfidence, } from "@app/types/fileContext"; import { indexedDBManager, @@ -36,6 +37,8 @@ export interface StoredStirlingFileRecord extends BaseFileMetadata { // group by label without re-reading PDF bytes, and it survives versioning. // See StirlingFileStub.classificationLabels. classificationLabels?: string[]; + // See StirlingFileStub.classificationConfidence. + classificationConfidence?: ClassificationConfidence; } export interface StorageStats { @@ -695,6 +698,7 @@ class FileStorageService { folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), classificationLabels: record.classificationLabels, + classificationConfidence: record.classificationConfidence, }; resolve(stub); @@ -762,6 +766,7 @@ class FileStorageService { folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), classificationLabels: record.classificationLabels, + classificationConfidence: record.classificationConfidence, }); } cursor.continue(); @@ -861,6 +866,7 @@ class FileStorageService { folderId: record.folderId ?? null, createdAt: record.createdAt || Date.now(), classificationLabels: record.classificationLabels, + classificationConfidence: record.classificationConfidence, }); } cursor.continue(); diff --git a/frontend/editor/src/core/types/fileContext.ts b/frontend/editor/src/core/types/fileContext.ts index 3dc00c306d..c482e8e54a 100644 --- a/frontend/editor/src/core/types/fileContext.ts +++ b/frontend/editor/src/core/types/fileContext.ts @@ -9,6 +9,9 @@ import { generateId } from "@app/utils/generateId"; // Re-export FileId for convenience export type { FileId }; +/** How sure a classifier was about the labels it produced. */ +export type ClassificationConfidence = "none" | "low" | "medium" | "high"; + // Normalized state types export interface ProcessedFilePage { thumbnail?: string; @@ -61,6 +64,11 @@ export interface StirlingFileStub extends BaseFileMetadata { * unclassified files / non-SaaS builds. */ classificationLabels?: string[]; + /** + * How sure the local heuristic was about {@link classificationLabels}: a confident verdict + * stands, an unsure one escalates to the AI. Undefined when the labels came from the AI. + */ + classificationConfidence?: ClassificationConfidence; /** * This session proved the stored bytes unreadable (WebKit losing a blob's * backing store). The row renders as "data lost" instead of pretending the diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index cea05ca20a..55938d231e 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -207,6 +207,9 @@ export interface AddWatermarkRequest { */ widthSpacer?: number; } +export interface AiToolsClassifyAndLabelRequest { + reclassify?: boolean; +} export interface AutoRotatePdfRequest { /** * Minimum Tesseract OSD orientation confidence required before a correction is applied. Matches OCRmyPDF's --rotate-pages-threshold scale @@ -1485,6 +1488,7 @@ export interface UrlToPdfRequest { /** Endpoint path for a generated tool operation (the operation identity across languages). */ export type ToolEndpoint = + | "/api/v1/ai/tools/classify-and-label" | "/api/v1/convert/cbr/pdf" | "/api/v1/convert/cbz/pdf" | "/api/v1/convert/ebook/pdf" @@ -1587,6 +1591,7 @@ export type ToolEndpoint = /** Backend request-parameter model for each tool endpoint. */ export interface ToolApiParams { + "/api/v1/ai/tools/classify-and-label": AiToolsClassifyAndLabelRequest; "/api/v1/convert/cbr/pdf": ConvertCbrToPdfRequest; "/api/v1/convert/cbz/pdf": ConvertCbzToPdfRequest; "/api/v1/convert/ebook/pdf": ConvertEbookToPdfRequest; @@ -1690,6 +1695,7 @@ export interface ToolApiParams { /** Every generated tool endpoint, for iteration. */ export const TOOL_ENDPOINTS = [ + "/api/v1/ai/tools/classify-and-label", "/api/v1/convert/cbr/pdf", "/api/v1/convert/cbz/pdf", "/api/v1/convert/ebook/pdf", diff --git a/frontend/editor/src/core/types/toolIO.ts b/frontend/editor/src/core/types/toolIO.ts index 4a03a52102..9d672b2d6d 100644 --- a/frontend/editor/src/core/types/toolIO.ts +++ b/frontend/editor/src/core/types/toolIO.ts @@ -99,6 +99,11 @@ export interface ToolIOSpec { export type ToolIOTable = Partial>; export const TOOL_IO: ToolIOTable = { + "/api/v1/ai/tools/classify-and-label": { + accepts: ["PDF"], + produces: "PDF", + arity: "SISO", + }, "/api/v1/convert/cbr/pdf": { accepts: ["CBR"], produces: "PDF", diff --git a/frontend/editor/src/core/types/toolId.ts b/frontend/editor/src/core/types/toolId.ts index 13799ad869..0762c2e81d 100644 --- a/frontend/editor/src/core/types/toolId.ts +++ b/frontend/editor/src/core/types/toolId.ts @@ -50,6 +50,7 @@ export const CORE_REGULAR_TOOL_IDS = [ "removeCertSign", "unlockPDFForms", "compress", + "classify", "extractPages", "reorganizePages", "extractImages", diff --git a/frontend/editor/src/core/utils/toolSearch.ts b/frontend/editor/src/core/utils/toolSearch.ts index 41b40135a5..1a543974a3 100644 --- a/frontend/editor/src/core/utils/toolSearch.ts +++ b/frontend/editor/src/core/utils/toolSearch.ts @@ -15,7 +15,11 @@ export function filterToolRegistryByQuery( toolRegistry: Partial, query: string, ): RankedToolItem[] { - const entries = Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]; + // The single funnel into the editor's tool list, so hiding here hides it everywhere the user + // browses - while getExecutableTools still offers it to a pipeline. + const entries = ( + Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][] + ).filter(([, tool]) => !tool?.hiddenFromToolList); if (!query.trim()) { return entries.map(([id, tool]) => ({ item: [id, tool] as [ToolId, ToolRegistryEntry], diff --git a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts index aac4b7e3da..64fd849d87 100644 --- a/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts +++ b/frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts @@ -1,5 +1,5 @@ -// With the AI engine off, the Classification policy runs here in the browser: -// each upload is labelled by the heuristic engine and metered for billing parity. +// The Classification policy's first pass: every upload is labelled locally before the AI is asked. +// The confidence reported here decides whether the AI is asked at all - see usePolicyAutoRun. import { useEffect, useRef, useState } from "react"; import { useAllFiles, useFileManagement } from "@app/contexts/FileContext"; @@ -7,7 +7,6 @@ import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { fileStorage } from "@app/services/fileStorage"; import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; -import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; import { scheduleIdle } from "@app/utils/scheduleIdle"; import { usePolicies } from "@app/hooks/usePolicies"; import { classifyFileHeuristically } from "@app/services/heuristic/heuristicClassification"; @@ -15,12 +14,14 @@ import { meterClassificationRun } from "@app/services/classificationMeter"; import { isDispatched, markDispatched, + recordRunStart, + updateRun, } from "@app/components/policies/policyRunStore"; import type { FileId } from "@app/types/file"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; +import type { HeuristicConfidence } from "@app/services/heuristic/types"; +import { CLASSIFICATION_CATEGORY_ID } from "@app/data/classificationPolicy"; -/** The category id of the Classification policy (see policyDefinitions). */ -const CLASSIFICATION_CATEGORY = "classification"; /** Files classified per idle pass, so a large library drains over several ticks. */ const CLASSIFY_BATCH = 3; /** How long to wait for an upload's bytes to land in IndexedDB (20 × 250ms ≈ 5s). @@ -47,9 +48,8 @@ export function useClientSideClassification(): void { const { bumpRevision } = useIndexedDB(); const { policies } = usePolicies(); const classificationEnabled = useClassificationEnabled(); - const aiEnabled = useAiEngineEnabled(); - // While app-config loads, aiEnabled reads false even on AI-on tenants; classifying - // in that window would double-run (and double-bill) files the server also labels. + // Still waited on: a verdict written before app-config lands would be acted on by the + // escalation decision before it knows whether the AI engine is even available. const { loading: configLoading } = useAppConfig(); // Files claimed this session, keyed id+lastModified so a new version is retried once. A claim is // taken synchronously right before classifying, so overlapping batches never double-classify. @@ -57,7 +57,9 @@ export function useClientSideClassification(): void { // Bumped after each batch to drain the next one. const [tick, setTick] = useState(0); - const policy = policies[CLASSIFICATION_CATEGORY]; + // TODO: keyed on the Classification CATEGORY, so a pipeline that merely contains a classify + // step gets no local pass - suppressing one step of a chain is not expressible today. + const policy = policies[CLASSIFICATION_CATEGORY_ID]; // Only when the admin has an active Classification policy - the same gate the AI path uses. const active = Boolean( policy?.configured && @@ -69,7 +71,8 @@ export function useClientSideClassification(): void { ); useEffect(() => { - if (configLoading || !classificationEnabled || aiEnabled || !active) { + // Runs whether or not the AI engine is on: it is the first pass either way, not a fallback. + if (configLoading || !classificationEnabled || !active) { return; } const claimKey = (s: StirlingFileStub) => @@ -95,17 +98,23 @@ export function useClientSideClassification(): void { // Re-validate at execution time - another batch may have claimed it since. if (claimed.current.has(key)) continue; claimed.current.add(key); - const labels = await classifyStub(stub.id as FileId, stub.name); + const verdict = await classifyStub( + stub.id as FileId, + stub.name, + stub.size ?? 0, + ); // Bytes never landed (file removed mid-wait): leave undelivered so a // reload (or new version) retries; the claim stops churn this session. - if (labels == null) continue; + if (verdict == null) continue; // Deliver unconditionally - a re-render must never discard a computed // (and already metered) result. Writes are idempotent. updateStirlingFileStub(stub.id as FileId, { - classificationLabels: labels, + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, }); const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { - classificationLabels: labels, + classificationLabels: verdict.labels, + classificationConfidence: verdict.confidence, }); if (ok) wrote = true; } @@ -122,7 +131,6 @@ export function useClientSideClassification(): void { fileStubs, active, classificationEnabled, - aiEnabled, configLoading, updateStirlingFileStub, bumpRevision, @@ -134,7 +142,8 @@ export function useClientSideClassification(): void { async function classifyStub( fileId: FileId, fileName: string, -): Promise { + fileSize: number, +): Promise<{ labels: string[]; confidence: HeuristicConfidence } | null> { let file: StirlingFile | null = null; for (let i = 0; i < FILE_WAIT_TRIES; i++) { file = await fileStorage.getStirlingFile(fileId).catch(() => null); @@ -149,10 +158,28 @@ async function classifyStub( } const debug = isClassificationDebug(); const startedAt = performance.now(); + // A local run is still a billable policy run, so it belongs in the activity feed; recorded only + // once the bytes are in hand, so a file whose bytes never land leaves no phantom row. + + // Read before recordRunStart, which takes the dispatch key itself and would otherwise always + // answer "already dispatched", silently stopping metering. + const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + const runId = `local-${CLASSIFICATION_CATEGORY_ID}-${fileId}-${Date.now()}`; + recordRunStart({ + runId, + categoryId: CLASSIFICATION_CATEGORY_ID, + fileId: fileId as string, + fileName, + fileSize, + target: "local", + status: "RUNNING", + outputs: [], + error: null, + startedAt: Date.now(), + }); try { const result = await classifyFileHeuristically(file, { explain: debug }); const { labels } = result; - const alreadyMetered = isDispatched(CLASSIFICATION_CATEGORY, fileId); const ms = Math.round(performance.now() - startedAt); const verdict = labels.length > 0 @@ -174,12 +201,22 @@ async function classifyStub( labels, }); } - markDispatched(CLASSIFICATION_CATEGORY, fileId); - return labels; + markDispatched(CLASSIFICATION_CATEGORY_ID, fileId); + // Labels, no output file - the same settle shape the server-run classification uses. + updateRun(runId, { + status: "COMPLETED", + imported: true, + outputFileIds: [fileId as string], + }); + return { labels, confidence: result.confidence }; } catch (err) { // Never persist a verdict for an unreadable file - the failure may be // environmental, so it must stay eligible to retry (and meter) later. console.warn(`[Classify] ${fileName}: could not be read, will retry`, err); + updateRun(runId, { + status: "FAILED", + error: err instanceof Error ? err.message : String(err), + }); return null; } } diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx index bc4fa54dff..08768f1aa7 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import type { ClassificationConfidence } from "@app/types/fileContext"; /** * Batch integration test (61 files, two chained upload policies) driving the real @@ -12,7 +13,11 @@ const FILE_COUNT = 61; // the workbench, mirrored into useAllFiles. consumeFiles mutates it in place // (input id → output id) exactly as the real silent reducer would. const mocks = vi.hoisted(() => ({ - workspace: [] as Array<{ id: string; classificationLabels?: string[] }>, + workspace: [] as Array<{ + id: string; + classificationLabels?: string[]; + classificationConfidence?: ClassificationConfidence; + }>, consumeSilentCalls: 0, consumeNonSilentCalls: 0, persistCalls: 0, @@ -117,10 +122,20 @@ function Harness() { return null; } +/** The heuristic verdict that escalates to the AI classifier; only "high" stands alone. */ +const LOW = "low" as const; + function replaceInWorkspace(inputIds: string[], outputIds: string[]) { + // A versioned output carries its input's heuristic verdict; the escalation decision is about the + // document, not about which step produced the current bytes. + const inherited = + mocks.workspace.find((s) => inputIds.includes(s.id)) + ?.classificationConfidence ?? LOW; mocks.workspace = mocks.workspace .filter((s) => !inputIds.includes(s.id)) - .concat(outputIds.map((id) => ({ id }))); + .concat( + outputIds.map((id) => ({ id, classificationConfidence: inherited })), + ); } beforeEach(() => { @@ -138,6 +153,7 @@ beforeEach(() => { mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ id: `file-${i}`, + classificationConfidence: LOW, })); mocks.listPolicyRuns.mockResolvedValue([]); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx index b67648e7fc..84b6f58bf7 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.race.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import type { ClassificationConfidence } from "@app/types/fileContext"; /** * Mid-run race: classification is in flight (its labelled output is still @@ -18,6 +19,7 @@ const mocks = vi.hoisted(() => ({ sourceFileIds?: string[]; derivedFromTool?: boolean; classificationLabels?: string[]; + classificationConfidence?: ClassificationConfidence; }>, runStoredPolicy: vi.fn(), getPolicyRun: vi.fn(), @@ -114,7 +116,7 @@ beforeEach(() => { resetPolicyRuns(); vi.clearAllMocks(); - mocks.workspace = [{ id: "file-0" }]; + mocks.workspace = [{ id: "file-0", classificationConfidence: "low" }]; mocks.listPolicyRuns.mockResolvedValue([]); mocks.getStirlingFile.mockResolvedValue( diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 7a2c4bda2e..8140691b6d 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -1,18 +1,6 @@ /** - * Auto-run controller: every enabled policy enforces on every uploaded file. - * Watches the session's files and fires a real backend run - * (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and - * recording progress in {@link policyRunStore} for the activity feed. - * - * When several policies enforce on the same trigger they run as an ordered chain: - * the first fires on the upload, and each subsequent policy fires on the previous - * one's output once it lands — so their effects accumulate in the admin-defined - * order rather than racing to fork the same version. - * - * Headless — call it from {@link PolicyAutoRunController}, which is mounted once - * wherever the editor is open so enforcement happens regardless of whether the - * policy panel is on screen. Each (policy, file) pair runs exactly once (tracked - * in the run store), so re-renders and remounts don't re-fire. + * Headless auto-run controller: one backend run per (policy, file), fired exactly once and polled. + * Policies sharing a trigger run as an ordered chain so their effects accumulate. */ import { useCallback, useEffect, useMemo, useRef } from "react"; @@ -39,7 +27,12 @@ import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { + policyDeliversOutputFiles, + policyRequiresAiEngine, + policyRewritesDocument, + shouldDispatchToAi, +} from "@app/data/classificationPolicy"; import { acquireDispatchSlot, releaseDispatchSlot, @@ -68,10 +61,8 @@ const POLL_MS = 2000; * sitting on an indeterminate spinner for a full poll interval. */ const FIRST_POLL_MS = 500; -/** The server aborts any single tool step that runs longer than its internal-API - * read timeout, then fails the run — so a run can legitimately stay in flight - * for up to this long per step. The client must keep polling at least that long, - * or it abandons a run the server is still working on (which reads as a hang). */ +/** Server's per-step abort budget - poll at least this long per step, or we abandon + * a run the server is still working on. */ const STEP_TIMEOUT_MS = 300_000; /** Slack on top of the per-step budget: queueing before the first step starts and @@ -91,11 +82,8 @@ const POLICY_QUEUE_FULL = "POLICY_QUEUE_FULL"; const MAX_QUEUE_RETRIES = 5; const QUEUE_RETRY_BASE_MS = 4000; -/** Consecutive "run not found" responses before giving up. The run state lives - * in memory on the server, so a restart or a second instance behind the load - * balancer makes a live run's status return 404 — and it won't come back. We - * tolerate a brief blip (e.g. a poll racing a just-dispatched run, or one hop - * to an instance that hasn't seen it) then fail, rather than polling forever. */ +/** Consecutive 404s before failing. Run state is in-memory server-side, so a restart + * or a hop to another instance loses it permanently - tolerate a blip, not forever. */ const MAX_NOT_FOUND = 3; /** A 404 (run status gone, or output file gone), across the web (axios) and @@ -118,24 +106,20 @@ function failRun(runId: string, message: string): void { updateRun(runId, { status: "FAILED", error: message, errorCode: null }); } -/** How long to wait for an upload's bytes to land in IndexedDB before giving up - * (20 × 250ms ≈ 5s). The stub can surface in the file list a beat before its - * bytes are committed, so a too-eager fetch would otherwise miss the file. */ +/** Wait for an upload's bytes to land in IndexedDB (~5s): the stub surfaces in the + * file list before its bytes are committed, so an eager fetch would miss the file. */ const FILE_WAIT_TRIES = 20; const FILE_WAIT_MS = 250; -/** - * A policy that changed nothing (redaction matched no text, say) completes with no - * output: nothing to deliver, but finished. Left unimported, the file's badge and - * its blocking overlay spin forever. - */ +/** A policy that changed nothing completes with no output; left unimported its badge + * and blocking overlay spin forever. */ export function finishedWithNothingToDeliver(run: PolicyRunRecord): boolean { return ( run.status === "COMPLETED" && !run.imported && (run.outputs?.length ?? 0) === 0 && - // Classification has its own settle path: labels, no output file. - !isClassificationCategory(run.categoryId) + // An annotating policy settles on labels, not an output file. + policyDeliversOutputFiles(run.categoryId) ); } @@ -155,34 +139,23 @@ export function usePolicyAutoRun(): void { const { policies } = usePolicies(); const aiEnabled = useAiEngineEnabled(); const runs = usePolicyRuns(); - // Live view of the workspace files, read inside the import effect WITHOUT making - // it a dependency. The silent consume that delivers an output mutates fileStubs, - // so if the import effect depended on fileStubs it would re-fire on its own - // delivery — an infinite import cascade (and a bumpRevision storm that trips - // React's max-update-depth). The effect only needs to fire when `runs` changes. + // Read in the import effect via ref, not as a dependency: delivery mutates fileStubs, + // so depending on them would re-fire the effect on its own delivery (infinite cascade). const fileStubsRef = useRef(fileStubs); fileStubsRef.current = fileStubs; - // Keys (run ids / dispatch keys) currently in flight, so the effects never - // double-fire across re-renders while their first async step is pending. + // Keys in flight, so effects never double-fire across re-renders while async work pends. const polling = useRef>(new Set()); const importing = useRef>(new Set()); const dispatching = useRef>(new Set()); // Reconcile against the backend exactly once per mount. const reconciled = useRef(false); - // A policy's tool calls run server-side, so a usage-limit 402 never reaches the apiClient - // interceptor (and thus never pops the modal that direct calls get). The backend surfaces the - // limit sentinel on the run's errorCode; when a run we polled finishes blocked, broadcast a - // window event. A saas-layer listener (which can read the wallet + open the modal — this - // proprietary hook can't import the saas modal API) decides free-limit vs spend-cap. Dedupe per - // run so a folder-watch burst opens the modal once, not once per file. + // Server-side runs never hit the apiClient 402 interceptor, so we broadcast the limit + // sentinel for a saas listener to open the modal. Deduped per run. const firedLimitModal = useRef>(new Set()); - // Active upload policies in execution order. When several enforce on upload they - // run as a chain — the first fires on the upload, each subsequent one on the - // previous policy's output — so their effects accumulate in a defined order - // instead of racing to fork the same version. Mirrors the dispatch filter - // (incl. the editor-source gate) so the chain honours the same eligibility. + // Active upload policies in chain order, so effects accumulate instead of racing to fork + // the same version. Mirrors the dispatch filter so the chain honours the same eligibility. const orderedUploadCategories = useMemo( () => Object.entries(policies) @@ -195,41 +168,40 @@ export function usePolicyAutoRun(): void { s.sources.length === 0 || s.sources.includes("editor")) && (s.runOn ?? "upload") === "upload" && - // Non-AI systems classify in the browser (useClientSideClassification), so keep the - // Classification policy out of the server chain when the AI engine is off. - !(id === "classification" && !aiEnabled), + // An escalation-only policy has nothing to do with no engine to escalate to. + !(policyRequiresAiEngine(id) && !aiEnabled), ) - // Classification runs last: it's non-blocking, so an enforcement policy - // running after it would fork a new version and drop the user's edits. + // Annotating policies run last: a rewriting one after them would fork a new + // version from the pre-annotation state and drop their labels. .sort(([idA, a], [idB, b]) => { - const ca = isClassificationCategory(idA) ? 1 : 0; - const cb = isClassificationCategory(idB) ? 1 : 0; - if (ca !== cb) return ca - cb; + const ra = policyRewritesDocument(idA) ? 0 : 1; + const rb = policyRewritesDocument(idB) ? 0 : 1; + if (ra !== rb) return ra - rb; return (a.order ?? 0) - (b.order ?? 0); }) .map(([id]) => id), [policies, aiEnabled], ); - // Runs whose chain-continuation we've already handled this session, so the next - // policy is dispatched exactly once per completed run. + // Chain-continuations handled this session, so the next policy fires once per run. const chained = useRef>(new Set()); // Latest policies, read from inside the stable retry callback (which has no deps). const policiesRef = useRef(policies); policiesRef.current = policies; + // Latest stubs for the chaining effect, which keys off runs and must not depend on stubs. + const stubsRef = useRef(fileStubs); + stubsRef.current = fileStubs; // Per-file (dispatchKey) count of consecutive queue-rejection retries, so backoff escalates and // eventually gives up. Survives the run-id changing on each retry; reset on any real outcome. const queueRetries = useRef>(new Map()); - // A queue-rejected run is just backpressure — drop the rejected record and fire a fresh run in - // its place after a growing backoff (one feed row, not a new one per attempt). Once the budget is - // spent, leave the last failure standing so the activity feed offers a manual Retry. + // Queue rejection is backpressure: replace the record with a fresh run after a backoff, so the + // feed keeps one row. Budget spent, leave the failure standing for a manual Retry. const scheduleQueueRetry = useCallback((runId: string) => { const rec = getRun(runId); if (!rec) return; - // A run rediscovered from the server (reconciled) has no local input fileId, so it can't be - // re-dispatched; leave it failed rather than spinning on a file we can't resolve. + // A reconciled run has no local fileId to re-dispatch; leave it failed. if (!rec.fileId) return; const key = dispatchKey(rec.categoryId, rec.fileId); const attempts = queueRetries.current.get(key) ?? 0; @@ -278,42 +250,38 @@ export function usePolicyAutoRun(): void { [scheduleQueueRetry], ); - // Dispatch: fire only the FIRST upload policy on each not-yet-run file. The rest - // of the chain is dispatched by the chaining effect below, each on the previous - // policy's output, so the policies apply cumulatively in order. + // Fire only the FIRST upload policy per file; the chaining effect below runs the rest + // on each previous output, so policies apply cumulatively in order. useEffect(() => { const firstCategory = orderedUploadCategories[0]; if (!firstCategory) return; const backendId = policies[firstCategory]?.backendId; if (!backendId) return; for (const stub of fileStubs) { - // Input-mode policies enforce only on files that actually entered the - // system as an upload — not on files a tool/automation produced in-app - // (versioned edits or independent artifacts like convert/split/merge). - // Those are enforced only by export-mode policies, at export time. + // Input-mode policies cover uploads only; tool-produced files are left to + // export-mode policies at export time. if (stub.derivedFromTool) continue; const key = dispatchKey(firstCategory, stub.id); - // Skip if already run (persisted) or a dispatch is in flight — the - // in-memory guard prevents double-firing during the async wait. + // Skip if already run (persisted) or in flight - the in-memory guard covers the async wait. if ( isDispatched(firstCategory, stub.id) || dispatching.current.has(key) ) { continue; } + // A confident local verdict stands; only an unsure one is escalated to the engine. + if (!shouldDispatchToAi(firstCategory, stub)) continue; dispatching.current.add(key); void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name) .catch(() => { - // runPolicyOnFile handles its own failures; this is just a backstop - // so an unexpected rejection never becomes an unhandled rejection. + // Backstop: runPolicyOnFile handles its own failures. }) .finally(() => dispatching.current.delete(key)); } }, [fileStubs, policies, orderedUploadCategories]); - // Chain: once a run has completed AND its output landed in the workspace, fire the - // next upload policy on that output. Only chains on success (a failed run has no - // output), and only once per run. isDispatched guards re-dispatch across reloads. + // Once a run's output lands, fire the next upload policy on it - success only, once per + // run. isDispatched guards re-dispatch across reloads. useEffect(() => { for (const run of runs) { if (run.status !== "COMPLETED" || !run.imported) continue; @@ -332,10 +300,15 @@ export function usePolicyAutoRun(): void { // Next policy not ready yet (still reconciling) — retry when policies change. if (!backendId) continue; chained.current.add(run.runId); - // Chain onto EVERY output, not just the first — a run that produced multiple files (split, - // ZIP-unpacked) must apply the next policy to all of them, or outputs 2..N silently skip it. + // Chain onto EVERY output: a run that produced several files (split, ZIP-unpacked) + // would otherwise silently skip the next policy on outputs 2..N. for (const outputId of outputIds) { if (isDispatched(nextCategory, outputId as FileId)) continue; + const outputStub = stubsRef.current.find((s) => s.id === outputId); + // Nothing to escalate: either the heuristic already answered confidently, or it has + // not reported yet and this effect re-runs when the verdict lands. + if (outputStub && !shouldDispatchToAi(nextCategory, outputStub)) + continue; void runPolicyOnFile( nextCategory, backendId, @@ -345,7 +318,7 @@ export function usePolicyAutoRun(): void { ).catch(() => {}); } } - }, [runs, policies, orderedUploadCategories]); + }, [runs, policies, orderedUploadCategories, fileStubs]); // Poll each in-flight run to a terminal state. useEffect(() => { @@ -358,11 +331,10 @@ export function usePolicyAutoRun(): void { } }, [runs, onRunFinished]); - // Import each completed run's outputs into the workspace (each output once), - // so the enforced file appears in the app rather than only on the backend. + // Import each completed run's outputs once, so the enforced file appears in the app. useEffect(() => { for (const run of runs) { - const classification = isClassificationCategory(run.categoryId); + const deliversFiles = policyDeliversOutputFiles(run.categoryId); if ( run.status !== "COMPLETED" || run.imported || @@ -375,13 +347,10 @@ export function usePolicyAutoRun(): void { continue; } importing.current.add(run.runId); - // Classification is metadata-only: stamp labels onto the current leaf of - // the file it ran on (no version fork). See importClassificationLabels. - if (classification) { - // Targets are resolved by importClassificationLabels AT WRITE TIME (not - // snapshotted here): its download/parse is an async window during which - // a manual tool run can consume the input and fork a new leaf, and a - // stale snapshot would no-op on the dead id and lose the labels. + // An annotating policy writes labels onto the current leaf; no version fork. + if (!deliversFiles) { + // Resolved at write time, not snapshotted: a tool run during the async parse can fork + // a new leaf, and a stale id would no-op and lose the labels. void importClassificationLabels( run, () => @@ -390,8 +359,7 @@ export function usePolicyAutoRun(): void { ).finally(() => importing.current.delete(run.runId)); continue; } - // Honour the policy's output mode: a new file, or a new version of the - // input file it ran on (needs that input's stub, still in the workspace). + // Output mode: a new file, or a new version of the input (needs its stub in the workspace). const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputName = policies[run.categoryId]?.outputName ?? ""; const outputNamePosition = policies[run.categoryId]?.outputNamePosition; @@ -410,9 +378,8 @@ export function usePolicyAutoRun(): void { firstUploadCategory: orderedUploadCategories[0], }).finally(() => importing.current.delete(run.runId)); } - // NB: fileStubs is intentionally NOT a dependency — it's read via a ref so a - // delivery's own workspace mutation can't re-trigger this effect (see the ref - // declaration above). The effect fires on run completions, which is all it needs. + // NB: fileStubs is read via a ref, not a dependency, so a delivery's own workspace + // mutation can't re-trigger this effect. }, [ runs, addFiles, @@ -422,11 +389,8 @@ export function usePolicyAutoRun(): void { orderedUploadCategories, ]); - // Reconcile against the backend on load. The server owns runs (durable, user-scoped), - // so a run started before this client recorded it, or before a refresh/crash, is - // rediscovered here; the poll + import effects above then collect its outputs rather - // than leaving them orphaned. Waits until policies are known so server runs can be - // attributed to their category. + // The server owns runs, so rediscover any this client never recorded and let the effects + // above collect their outputs. Waits for policies so runs can be attributed to a category. useEffect(() => { if (reconciled.current) return; if (Object.keys(policies).length === 0) return; @@ -457,23 +421,18 @@ interface ImportContext { outputMode: "new_file" | "new_version"; /** Rename rule. Empty → keep the input's filename. */ outputName: string; - /** Where the rename is applied: before ("prefix") or after ("suffix") the - * base filename. Defaults to "suffix" when absent. */ + /** Rename position around the base filename; defaults to "suffix" when absent. */ outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** The input file's stub — required to version it; absent if it's been removed. */ parentStub: StirlingFileStub | undefined; - /** The first upload policy in the chain — the only one the dispatch effect ever - * fires. Every policy output is marked dispatched for it so a downstream policy's - * output is never mistaken for a fresh upload and re-enforced (an endless loop). */ + /** The only policy the dispatch effect fires; every output is marked dispatched for it + * so a downstream output is never mistaken for a fresh upload and re-enforced. */ firstUploadCategory: string | undefined; } /** - * Pull the caller's server-side runs and fold them into the local store. For a run we already - * track, patch its status/outputs (preserving local import progress + attribution); for one we - * don't, adopt it for feed visibility (polled if still live, but never auto-imported — see the - * `imported` note below). Server-excluded ad-hoc runs and runs we can't map to a configured - * category are skipped. + * Fold server-side runs into the local store: patch tracked ones, adopt untracked ones for feed + * visibility only. Unmappable and ad-hoc runs are skipped. */ function applyOutputName( inputFileName: string, @@ -489,8 +448,7 @@ function applyOutputName( : `${base}_${outputName}${ext}`; } -/** The next upload policy after {@code categoryId} in the chain, or undefined if - * it's last or no longer in the ordered set (e.g. paused since it ran). */ +/** Next upload policy in the chain, or undefined if last or no longer eligible. */ function nextUploadCategory( orderedUploadCategories: string[], categoryId: string, @@ -522,13 +480,11 @@ async function reconcileServerRuns( addReconciledRun({ runId: view.runId, categoryId, - // No local input link: a run rediscovered purely from the server was never recorded by - // this client, so it can't be tied back to a workspace/storage file (and isn't retried). + // Server-only run: never recorded here, so it can't be tied to a file (and isn't retried). fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, - // Rediscovered from the SaaS run registry (listPolicyRuns), so its outputs - // live on the cloud backend. + // From the SaaS run registry, so its outputs live on the cloud backend. target: "saas", status: view.status, outputs: view.outputs, @@ -560,10 +516,8 @@ interface ClassificationImportContext { bumpRevision: () => void; } -/** Workspace stubs to tag with a classification run's labels: the file it ran - * on plus any live descendants, so an edit made during the async run (which - * forks a new leaf) still shows the tags. Empty once the document has left the - * workspace (closed, or a reconciled run with no local input link). */ +/** The run's file plus live descendants, so an edit during the run (which forks a new leaf) + * still shows the tags. Empty once the document has left the workspace. */ export function classificationLabelTargetStubs( runFileId: string, stubs: ReadonlyArray, @@ -576,19 +530,14 @@ export function classificationLabelTargetStubs( ); } -/** Attempts to read a completed run's labels before giving up, and the backoff - * between them (delay × attempt). The import effect only re-runs when the run - * store changes, so a transient read failure has to be retried HERE: bailing - * out would leave the run unsettled and the file's "running" pill spinning - * until unrelated policy activity happened to nudge the effect. */ +/** Label-read attempts and backoff. Retried HERE because the import effect only re-runs on + * run-store changes, so bailing out would leave the file's "running" pill spinning. */ const LABEL_READ_ATTEMPTS = 3; const LABEL_READ_RETRY_MS = 2000; /** - * Read classification labels out of a completed run's output PDF. A 404 means - * that output aged out, so it's skipped; any other failure is transient and - * retried with backoff. Returns null when there are genuinely no labels to - * apply (including a run with no outputs), so the caller can settle the run. + * Read labels from a completed run's output PDF: a 404 means it aged out and is skipped, other + * failures retry with backoff. Null means no labels to apply, so the caller settles the run. */ async function readRunLabels(run: PolicyRunRecord): Promise { for (let attempt = 0; attempt < LABEL_READ_ATTEMPTS; attempt++) { @@ -609,21 +558,13 @@ async function readRunLabels(run: PolicyRunRecord): Promise { // Every output was read (or had aged out): there are no labels to apply. if (!transientFailure) return null; } - // Out of attempts. Settle the run unlabelled rather than spin forever; the - // file keeps its classification badge, just without tags. + // Out of attempts: settle unlabelled rather than spin forever - the badge stays, tags don't. return null; } /** - * Stamp `labels` onto the run's live descendants in place (workspace + storage) - * — no versioned child, no history entry, only tags. Returns the tagged ids. - * - * Runs twice, because `resolveTargets` reads a rendered snapshot of the - * workspace: a CONSUME_FILES that was dispatched but not yet rendered when the - * first pass ran leaves its target already gone by the time UPDATE_FILE_RECORD - * is processed, so that stamp no-ops and the labels would be silently lost. The - * second pass sees the forked leaf and tags it. Each id is stamped at most once - * across both passes, so the pass costs nothing when no consume raced. + * Stamp `labels` in place (workspace + storage) - tags only, no versioned child. Two passes + * because a consume racing the first would strand its target and lose the labels. */ async function stampClassificationLabels( labels: string[], @@ -634,10 +575,8 @@ async function stampClassificationLabels( const tagged = new Set(); for (let pass = 0; pass < 2; pass++) { - // Resolve and stamp the store in one synchronous block — no await between - // them, so a target can't be consumed in between. A consume AFTER the stamp - // is safe too: the CONSUME_FILES reducer carries classificationLabels onto - // the new leaf. + // Resolve and stamp synchronously so no consume lands in between; a consume after the + // stamp is safe, as the reducer carries the labels onto the new leaf. const fresh = resolveTargets().filter((s) => !tagged.has(s.id)); for (const stub of fresh) { tagged.add(stub.id); @@ -651,25 +590,20 @@ async function stampClassificationLabels( } if (mutated) ctx.bumpRevision(); - // Yield a macrotask so React processes this pass's stamps (and any consume - // that raced them) before the next pass re-resolves. + // Yield a macrotask so React processes this pass's stamps before the next re-resolves. if (pass === 0) await new Promise((resolve) => setTimeout(resolve)); } return Array.from(tagged); } -/** - * Deliver a classification run: read its labels and tag the live document with - * them. Metadata-only — nothing is versioned. - */ +/** Deliver a classification run: read its labels and tag the live document. Nothing is versioned. */ async function importClassificationLabels( run: PolicyRunRecord, resolveTargets: () => StirlingFileStub[], ctx: ClassificationImportContext, ): Promise { if (resolveTargets().length === 0) { - // The document left the workspace (closed, or a server-reconciled run with - // no local input link) — nothing to tag. + // The document left the workspace - nothing to tag. updateRun(run.runId, { imported: true }); return; } @@ -678,9 +612,8 @@ async function importClassificationLabels( labels && labels.length > 0 ? await stampClassificationLabels(labels, resolveTargets, ctx) : []; - // Settle either way so it stops re-importing. outputFileIds are the TAGGED - // workspace files (no forked version), so their policy badge persists. Safe - // to chain-key on: classification is always last, so nothing chains off it. + // Settle either way so it stops re-importing. outputFileIds are the TAGGED files, so their + // badge persists; safe to chain-key on, as classification is always last. updateRun(run.runId, { imported: true, importedFileIds: run.outputs.map((o) => o.fileId), @@ -689,16 +622,8 @@ async function importClassificationLabels( } /** - * Fetch a completed run's not-yet-imported output files and deliver them to the - * workspace. Per-output, via allSettled: each output is tracked once delivered, - * so a partial failure retries only the missing files on a later tick and the - * ones that succeeded are never added twice. `imported` flips true only once - * every output has landed. - * - * Delivery honours the policy's output mode: "new_version" replaces the input - * file with a versioned child (its history chain), "new_file" adds the output - * as a standalone file. Versioning falls back to a new file if the input is - * gone (no parent stub). + * Deliver a run's outputs per-output, so a partial failure retries only the missing files and + * successes are never added twice. Honours the output mode; versioning needs the parent stub. */ async function importOutputs( run: PolicyRunRecord, @@ -711,9 +636,8 @@ async function importOutputs( return; } - // Keep the input's original filename unless a rename rule is set — without a - // rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would - // otherwise rename every output. + // Keep the input's filename unless a rename rule is set, else the backend's auto-suffixed + // name renames every output. const targetName = ctx.outputName ? applyOutputName( run.fileName, diff --git a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx index 1f04ea22ca..d5a2e7537d 100644 --- a/frontend/editor/src/proprietary/components/viewer/Viewer.tsx +++ b/frontend/editor/src/proprietary/components/viewer/Viewer.tsx @@ -8,7 +8,7 @@ import { usePolicyRuns, type PolicyRunRecord, } from "@app/components/policies/policyRunStore"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { isClassificationCategory } from "@app/data/classificationPolicy"; import { PolicyEnforcementOverlay } from "@app/components/viewer/PolicyEnforcementOverlay"; type SignatureOverlayPassThrough = Pick< diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.test.ts b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts new file mode 100644 index 0000000000..d3cb55c043 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationPolicy.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + isClassificationCategory, + orderRewritesFirst, + policyDeliversOutputFiles, + policyRequiresAiEngine, + policyRewritesDocument, + shouldDispatchToAi, +} from "@app/data/classificationPolicy"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +const stub = ( + confidence?: StirlingFileStub["classificationConfidence"], +): StirlingFileStub => + ({ classificationConfidence: confidence }) as StirlingFileStub; + +describe("isClassificationCategory", () => { + it("recognises the classification category and nothing else", () => { + expect(isClassificationCategory("classification")).toBe(true); + expect(isClassificationCategory("security")).toBe(false); + expect(isClassificationCategory("")).toBe(false); + }); +}); + +describe("policy capabilities", () => { + it("treats classification as annotating, everything else as rewriting", () => { + expect(policyRewritesDocument("security")).toBe(true); + expect(policyRewritesDocument("classification")).toBe(false); + // A builder pipeline (no catalogue category) runs tools, so it rewrites. + expect(policyRewritesDocument("pipeline-abc123")).toBe(true); + }); + + it("expects output files from rewriting policies only", () => { + expect(policyDeliversOutputFiles("security")).toBe(true); + expect(policyDeliversOutputFiles("classification")).toBe(false); + }); + + it("marks classification as the AI-escalation policy", () => { + expect(policyRequiresAiEngine("classification")).toBe(true); + expect(policyRequiresAiEngine("security")).toBe(false); + }); +}); + +describe("orderRewritesFirst", () => { + it("moves annotating policies to the end, preserving other order", () => { + expect( + orderRewritesFirst(["classification", "security", "compliance"]), + ).toEqual(["security", "compliance", "classification"]); + }); + + it("leaves an order without an annotating policy untouched", () => { + expect(orderRewritesFirst(["security", "compliance"])).toEqual([ + "security", + "compliance", + ]); + }); + + it("is a no-op when the annotating policy is already last", () => { + expect(orderRewritesFirst(["security", "classification"])).toEqual([ + "security", + "classification", + ]); + }); + + it("handles the annotating policy as the only one", () => { + expect(orderRewritesFirst(["classification"])).toEqual(["classification"]); + }); +}); + +describe("shouldDispatchToAi", () => { + it("always dispatches a policy that is not classification", () => { + expect(shouldDispatchToAi("security", stub())).toBe(true); + expect(shouldDispatchToAi("security", stub("high"))).toBe(true); + }); + + it("holds back until the local heuristic has reported", () => { + // Not a skip: dispatching now races the local pass and pays for a free answer; + // the caller re-evaluates once the verdict lands. + expect(shouldDispatchToAi("classification", stub())).toBe(false); + }); + + it("lets a confident local verdict stand", () => { + expect(shouldDispatchToAi("classification", stub("high"))).toBe(false); + }); + + it("escalates anything less than confident", () => { + expect(shouldDispatchToAi("classification", stub("medium"))).toBe(true); + expect(shouldDispatchToAi("classification", stub("low"))).toBe(true); + expect(shouldDispatchToAi("classification", stub("none"))).toBe(true); + }); +}); diff --git a/frontend/editor/src/proprietary/data/classificationPolicy.ts b/frontend/editor/src/proprietary/data/classificationPolicy.ts new file mode 100644 index 0000000000..4327b40d39 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationPolicy.ts @@ -0,0 +1,69 @@ +/** + * Everything specific to the built-in Classification policy, in one module. The generic policy + * runner asks the capability questions below instead of naming classification itself, so a second + * annotating policy needs a change here rather than in the runner. + * + * These are still keyed on the category id rather than a property each policy declares. That is + * deliberate for now: policies are becoming pipelines with labels behind a separate enforcement + * layer, which removes the category concept these would be declared against. Classification also + * stays genuinely privileged - it is the only policy with a browser-side implementation, so it can + * answer without the server. Ordering and output shape belong in that rework (an in-place output + * mode, and a run result that can carry findings as well as files), not in a flag added here first. + */ + +import type { + ClassificationConfidence, + StirlingFileStub, +} from "@app/types/fileContext"; + +/** Catalogue category id of the built-in Classification policy. */ +export const CLASSIFICATION_CATEGORY_ID = "classification"; + +export function isClassificationCategory(categoryId: string): boolean { + return categoryId === CLASSIFICATION_CATEGORY_ID; +} + +/** + * Whether the policy rewrites the document rather than only annotating it. Annotating policies are + * ordered last: a rewriting one after them would fork from the pre-annotation version. + */ +export function policyRewritesDocument(categoryId: string): boolean { + return !isClassificationCategory(categoryId); +} + +/** Whether a completed run is expected to deliver output files (annotators deliver labels). */ +export function policyDeliversOutputFiles(categoryId: string): boolean { + return policyRewritesDocument(categoryId); +} + +/** Whether the policy's server-side run exists only to escalate to the AI engine. */ +export function policyRequiresAiEngine(categoryId: string): boolean { + return isClassificationCategory(categoryId); +} + +/** Order annotating policies last; everything else keeps the order it was given. */ +export function orderRewritesFirst(categoryIds: string[]): string[] { + return [ + ...categoryIds.filter(policyRewritesDocument), + ...categoryIds.filter((id) => !policyRewritesDocument(id)), + ]; +} + +/** + * The one heuristic verdict trusted to stand on its own; anything less escalates to the AI, which + * overwrites it. Deliberately strict - a wrong label costs more than an engine call. + */ +const TRUSTED_CONFIDENCE: ClassificationConfidence = "high"; + +/** + * Whether the AI classifier should be asked about this file. Only once the heuristic has reported: + * dispatching before then races the first pass and bills for an answer it was about to produce. + */ +export function shouldDispatchToAi( + categoryId: string, + stub: StirlingFileStub, +): boolean { + if (!isClassificationCategory(categoryId)) return true; + const confidence = stub.classificationConfidence; + return confidence != null && confidence !== TRUSTED_CONFIDENCE; +} diff --git a/frontend/editor/src/proprietary/data/policyCategories.test.ts b/frontend/editor/src/proprietary/data/policyCategories.test.ts deleted file mode 100644 index 4b4fca2b7e..0000000000 --- a/frontend/editor/src/proprietary/data/policyCategories.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - isClassificationCategory, - pinClassificationLast, -} from "@app/data/policyCategories"; - -describe("isClassificationCategory", () => { - it("recognises the classification category and nothing else", () => { - expect(isClassificationCategory("classification")).toBe(true); - expect(isClassificationCategory("security")).toBe(false); - expect(isClassificationCategory("")).toBe(false); - }); -}); - -describe("pinClassificationLast", () => { - it("moves classification to the end, preserving other order", () => { - expect( - pinClassificationLast(["classification", "security", "compliance"]), - ).toEqual(["security", "compliance", "classification"]); - }); - - it("leaves an order without classification untouched", () => { - expect(pinClassificationLast(["security", "compliance"])).toEqual([ - "security", - "compliance", - ]); - }); - - it("is a no-op when classification is already last", () => { - expect(pinClassificationLast(["security", "classification"])).toEqual([ - "security", - "classification", - ]); - }); - - it("handles classification as the only policy", () => { - expect(pinClassificationLast(["classification"])).toEqual([ - "classification", - ]); - }); -}); diff --git a/frontend/editor/src/proprietary/data/policyCategories.ts b/frontend/editor/src/proprietary/data/policyCategories.ts deleted file mode 100644 index 820a93366b..0000000000 --- a/frontend/editor/src/proprietary/data/policyCategories.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** The classification policy's catalog category id. */ -export const CLASSIFICATION_CATEGORY_ID = "classification"; - -/** - * Classification is metadata-only: it runs async (never blocks), never forks a - * version, and always runs last. This predicate gates that special handling. - */ -export function isClassificationCategory(categoryId: string): boolean { - return categoryId === CLASSIFICATION_CATEGORY_ID; -} - -/** - * Move classification to the end of an execution order (others keep their order), - * so a persisted/displayed order can't place it anywhere but last. - */ -export function pinClassificationLast(orderedCategoryIds: string[]): string[] { - return [ - ...orderedCategoryIds.filter((id) => !isClassificationCategory(id)), - ...orderedCategoryIds.filter((id) => isClassificationCategory(id)), - ]; -} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 389f98cd65..382a7f9222 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -35,7 +35,7 @@ import { removePolicy, } from "@app/services/policyBackend"; import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; -import { pinClassificationLast } from "@app/data/policyCategories"; +import { orderRewritesFirst } from "@app/data/classificationPolicy"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -327,9 +327,9 @@ export function usePolicies() { * first for an instant re-render; the next reconcile re-reads the server order. */ const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { - // Pin classification last so the persisted/server order matches execution - // (it always runs last — see usePolicyAutoRun). - const ordered = pinClassificationLast(orderedCategoryIds); + // Annotating policies last, so the persisted order matches execution order + // (see usePolicyAutoRun). + const ordered = orderRewritesFirst(orderedCategoryIds); persistPolicyOrder(ordered); const current = loadPolicies(); const backendIds = ordered diff --git a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts index 52779b9cf8..85b2249cbb 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyFileBadges.ts @@ -4,7 +4,7 @@ import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; import { useAllFiles } from "@app/contexts/FileContext"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { policyAccentVar } from "@app/components/policies/policyStatus"; -import { isClassificationCategory } from "@app/data/policyCategories"; +import { isClassificationCategory } from "@app/data/classificationPolicy"; import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges"; /** Minimal provenance shape needed to resolve a file's inherited badges. */ diff --git a/frontend/editor/src/proprietary/services/heuristic/types.ts b/frontend/editor/src/proprietary/services/heuristic/types.ts index fbd8128ba4..5170feccd5 100644 --- a/frontend/editor/src/proprietary/services/heuristic/types.ts +++ b/frontend/editor/src/proprietary/services/heuristic/types.ts @@ -1,5 +1,7 @@ // Shared types for the client-side heuristic (non-AI) document classifier. +import type { ClassificationConfidence } from "@app/types/fileContext"; + /** Input document for the heuristic engine. */ export interface HeuristicDoc { fileName: string; @@ -11,7 +13,7 @@ export interface HeuristicDoc { } // "none" = no match or non-English; a real runtime value, not just a type state. -export type HeuristicConfidence = "none" | "low" | "medium" | "high"; +export type HeuristicConfidence = ClassificationConfidence; /** One scored candidate label with the rule hits that produced its score (debug only). */ export interface LabelScoreExplanation { From 158187ac464914707e4e6e5de3764e9181d989b3 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 24 Aug 2026 15:06:22 +0000 Subject: [PATCH 07/10] Fix Documents tab in Processor (#7569) # Description of Changes The Documents tab in the Processor is supposed to be available to all Processor users, but because the API is built on top of the Audit data, which is only for enterprise users, the API call always fails with 403. This means that it never fills the query cache, so every time you go back to the tab it has to reload all the data for a couple of seconds (and will fail again). This fixes the API so that it's available to any Processor user instead of just enterprise users. Also, the documents data was only being written to the log on an enterprise license, so I've changed it so that data is always tracked in the audit log because otherwise the Documents tab would still be useless to non-enterprise users. The Audit Log tab was also available to all Processor users, but would have the same issue where the table would never load because the API would 403 as well. I've just made the Audit Log tab disabled for non-enterprise users now. We might want to do something to signpost it a bit more that it's an enterprise-specific feature, but it's better than nothing for now. --- .../audit/ControllerAuditAspect.java | 33 +++++---- .../DefaultPortalDocumentsScopeResolver.java | 13 ++++ .../audit/PortalDocumentsScopeResolver.java | 7 ++ .../api/PortalDocumentsController.java | 20 ++++-- .../service/AuditCleanupService.java | 36 +++++++++- .../proprietary/service/AuditService.java | 71 ++++++++++++++----- .../audit/ControllerAuditAspectTest.java | 21 ++++-- .../service/AuditCleanupServiceTest.java | 55 ++++++++++++++ .../proprietary/service/AuditServiceTest.java | 36 ++++++++++ .../SaasPortalDocumentsScopeResolver.java | 46 ++++++++++++ frontend/editor/src/core/query/queryClient.ts | 11 ++- .../portal-saas/hooks/useEnterpriseEnabled.ts | 13 ++++ .../portal-saas/views/Infrastructure.test.tsx | 23 +++++- .../src/portal-saas/views/Infrastructure.tsx | 9 ++- .../components/infrastructure/AuditTab.tsx | 3 + .../src/portal/hooks/useEnterpriseEnabled.ts | 17 +++++ .../src/portal/views/Infrastructure.test.tsx | 32 ++++++++- .../src/portal/views/Infrastructure.tsx | 24 +++++-- 18 files changed, 410 insertions(+), 60 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java create mode 100644 frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts create mode 100644 frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java index 2f75b755e0..e6af47129f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/ControllerAuditAspect.java @@ -91,24 +91,32 @@ public class ControllerAuditAspect { MethodSignature sig = (MethodSignature) joinPoint.getSignature(); Method method = sig.getMethod(); - // Fast path: check if auditing is enabled before doing any work - // This avoids all data collection if auditing is disabled - if (!auditService.shouldAudit(method, auditConfig)) { + // Resolve the event type up front so the enterprise gate can be type-aware: document + // processing events (the Documents tab's data source) are audited without an Enterprise + // license, while the rest of the audit log stays Enterprise-only. resolveEventType is cheap + // (annotation / class / path checks), so it's safe on the pre-record fast path. + Audited auditedAnnotation = method.getAnnotation(Audited.class); + String path = getRequestPath(method, httpMethod); + AuditEventType eventType = + auditService.resolveEventType( + method, + joinPoint.getTarget().getClass(), + path, + httpMethod, + auditedAnnotation); + + // Fast path: skip all data collection when this event won't be recorded. + if (!auditService.shouldAudit(eventType, method, auditConfig)) { return joinPoint.proceed(); } - // Check if method is explicitly annotated with @Audited - Audited auditedAnnotation = method.getAnnotation(Audited.class); AuditLevel level = auditConfig.getAuditLevel(); - // If @Audited annotation is present, respect its level setting if (auditedAnnotation != null) { // Use the level from annotation if it's stricter than global level level = auditedAnnotation.level(); } - String path = getRequestPath(method, httpMethod); - // Skip static GET resources if ("GET".equals(httpMethod)) { HttpServletRequest maybe = auditService.getCurrentRequest(); @@ -209,15 +217,6 @@ public class ControllerAuditAspect { // the body ran, so it must happen here rather than with the pre-proceed HTTP data). auditService.addAutomationContext(data, req); - // Resolve the event type using the unified method - AuditEventType eventType = - auditService.resolveEventType( - method, - joinPoint.getTarget().getClass(), - path, - httpMethod, - auditedAnnotation); - // Add result only if operation result capture is explicitly enabled // Skip result for UI_DATA events to avoid storing large response bodies if (auditService.shouldCaptureOperationResults() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java new file mode 100644 index 0000000000..066e74f4c5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/DefaultPortalDocumentsScopeResolver.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.audit; + +import org.springframework.stereotype.Component; + +/** Self-hosted default: any portal user sees the whole-server documents queue. */ +@Component +public class DefaultPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver { + + @Override + public PortalAuditScope resolve() { + return PortalAuditScope.server(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java new file mode 100644 index 0000000000..2767de0630 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/PortalDocumentsScopeResolver.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.audit; + +/** Resolves which slice of the documents queue a portal user may see. */ +public interface PortalDocumentsScopeResolver { + + PortalAuditScope resolve(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java index 14146eacb4..f6d094bde4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/PortalDocumentsController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.controller.api; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -11,19 +12,25 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.annotations.api.ProprietaryUiDataApi; import stirling.software.proprietary.audit.PortalAuditScope; -import stirling.software.proprietary.audit.PortalAuditScopeResolver; +import stirling.software.proprietary.audit.PortalDocumentsScopeResolver; import stirling.software.proprietary.model.api.documents.PortalDocumentsResponseDto; -import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.service.PortalDocumentsService; -/** Serves the portal Documents review queue, derived from real audit data and scoped per caller. */ +/** + * Serves the portal Documents review queue, derived from real audit data and scoped per caller. + * + *

Open to every portal user (not Enterprise-gated): the Documents tab is a core Processor + * feature. Access is enforced by {@code @resourceAccess.canUsePortal()}; visibility is then + * resolved per deployment - self-hosted portal users see the whole server, SaaS users see their + * team (see {@link PortalDocumentsScopeResolver}). + */ @ProprietaryUiDataApi @RequiredArgsConstructor -@EnterpriseEndpoint +@PreAuthorize("@resourceAccess.canUsePortal()") public class PortalDocumentsController { private final PortalDocumentsService portalDocumentsService; - private final PortalAuditScopeResolver auditScopeResolver; + private final PortalDocumentsScopeResolver documentsScopeResolver; // tier accepted for mock-seam symmetry; ignored (queue isn't tier-scoped). @GetMapping("/documents") @@ -32,8 +39,9 @@ public class PortalDocumentsController { description = "Files processed through the org, derived from the audit trail.") public ResponseEntity getDocuments( @RequestParam(value = "tier", required = false) String tier) { - PortalAuditScope scope = auditScopeResolver.resolve(); + PortalAuditScope scope = documentsScopeResolver.resolve(); if (!scope.allowed()) { + // SaaS caller with no team has nothing to show; surface an empty tab, not a 500. return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } PortalDocumentsResponseDto body = diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java index 8a70a1b7a4..df1d762963 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditCleanupService.java @@ -5,13 +5,13 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.concurrent.TimeUnit; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.config.AuditConfigurationProperties; @@ -20,15 +20,31 @@ import stirling.software.proprietary.repository.PersistentAuditEventRepository; /** Service to periodically clean up old audit events based on retention policy. */ @Slf4j @Service -@RequiredArgsConstructor public class AuditCleanupService { private final PersistentAuditEventRepository auditRepository; private final AuditConfigurationProperties auditConfig; + private final boolean runningEE; // Default batch size for deletions private static final int BATCH_SIZE = 10000; + /** + * Maximum audit retention on non-Enterprise instances. Audit events feed the Documents tab on + * every instance, but longer history is an Enterprise feature - so non-EE deployments keep a + * shorter window ("infinite" included), bounding the always-on trail off-license. + */ + private static final int NON_EE_MAX_RETENTION_DAYS = 30; + + public AuditCleanupService( + PersistentAuditEventRepository auditRepository, + AuditConfigurationProperties auditConfig, + @Qualifier("runningEE") boolean runningEE) { + this.auditRepository = auditRepository; + this.auditConfig = auditConfig; + this.runningEE = runningEE; + } + /** * Scheduled task that runs daily to clean up old audit events. The retention period is * configurable in settings.yml. @@ -39,7 +55,7 @@ public class AuditCleanupService { return; } - int retentionDays = auditConfig.getRetentionDays(); + int retentionDays = effectiveRetentionDays(); if (retentionDays <= 0) { return; } @@ -58,6 +74,20 @@ public class AuditCleanupService { } } + /** + * The retention window actually applied. Enterprise uses the configured value (0 = infinite); + * non-Enterprise is clamped to {@link #NON_EE_MAX_RETENTION_DAYS}. + */ + int effectiveRetentionDays() { + int configured = auditConfig.getRetentionDays(); + if (runningEE) { + return configured; + } + return configured <= 0 + ? NON_EE_MAX_RETENTION_DAYS + : Math.min(configured, NON_EE_MAX_RETENTION_DAYS); + } + /** * Performs batch deletion of events to prevent long-running transactions and potential database * locks. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java index 2fee8b5c7c..87fa165826 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AuditService.java @@ -87,10 +87,7 @@ public class AuditService { * @param level The minimum audit level required for this event to be logged */ public void audit(AuditEventType type, Map data, AuditLevel level) { - // Skip auditing if this level is not enabled or if not Enterprise edition - if (!auditConfig.isEnabled() - || !auditConfig.getAuditLevel().includes(level) - || !runningEE) { + if (!shouldRecord(type, level)) { return; } @@ -126,8 +123,7 @@ public class AuditService { */ public void audit( String principal, AuditEventType type, Map data, AuditLevel level) { - // Skip auditing if this level is not enabled or if not Enterprise edition - if (!auditConfig.isLevelEnabled(level) || !runningEE) { + if (!shouldRecord(type, level)) { return; } @@ -156,8 +152,7 @@ public class AuditService { * @param level The minimum audit level required for this event to be logged */ public void audit(String type, Map data, AuditLevel level) { - // Skip auditing if this level is not enabled or if not Enterprise edition - if (!auditConfig.isLevelEnabled(level) || !runningEE) { + if (!shouldRecord(type, level)) { return; } @@ -192,8 +187,7 @@ public class AuditService { * @param level The minimum audit level required for this event to be logged */ public void audit(String principal, String type, Map data, AuditLevel level) { - // Skip auditing if this level is not enabled or if not Enterprise edition - if (!auditConfig.isLevelEnabled(level) || !runningEE) { + if (!shouldRecord(type, level)) { return; } @@ -223,9 +217,7 @@ public class AuditService { AuditEventType type, Map data, AuditLevel level) { - if (!auditConfig.isEnabled() - || !auditConfig.getAuditLevel().includes(level) - || !runningEE) { + if (!shouldRecord(type, level)) { return; } @@ -250,9 +242,7 @@ public class AuditService { String type, Map data, AuditLevel level) { - if (!auditConfig.isEnabled() - || !auditConfig.getAuditLevel().includes(level) - || !runningEE) { + if (!shouldRecord(type, level)) { return; } @@ -626,6 +616,55 @@ public class AuditService { return auditConfig.getAuditLevel().includes(requiredLevel); } + /** + * Type-aware variant used by the controller aspect, which resolves the event type before + * deciding whether to record. Document-processing events feed the Documents tab (available to + * every Processor user), so they audit without an Enterprise license; the rest of the audit log + * stays Enterprise-only. + */ + public boolean shouldAudit( + AuditEventType eventType, Method method, AuditConfigurationProperties auditConfig) { + if (!auditConfig.isEnabled() || !isLicensedToRecord(eventType)) { + return false; + } + + Audited auditedAnnotation = method.getAnnotation(Audited.class); + AuditLevel requiredLevel = + (auditedAnnotation != null) ? auditedAnnotation.level() : AuditLevel.BASIC; + + return auditConfig.getAuditLevel().includes(requiredLevel); + } + + /** + * Whether an event of this type and level should be persisted: the configured audit level must + * include it and the current license must permit recording it. + */ + private boolean shouldRecord(AuditEventType type, AuditLevel level) { + return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type); + } + + private boolean shouldRecord(String type, AuditLevel level) { + return auditConfig.isLevelEnabled(level) && isLicensedToRecord(type); + } + + /** + * Whether the current license permits recording this event type. Enterprise records everything; + * without it only document-processing events (PDF_PROCESS, FILE_OPERATION) are captured, + * because they back the Documents tab that is open to every Processor user (still subject to + * audit being enabled at a level that includes them). Everything else stays Enterprise-only. + */ + private boolean isLicensedToRecord(AuditEventType type) { + return runningEE + || type == AuditEventType.PDF_PROCESS + || type == AuditEventType.FILE_OPERATION; + } + + private boolean isLicensedToRecord(String type) { + return runningEE + || AuditEventType.PDF_PROCESS.name().equals(type) + || AuditEventType.FILE_OPERATION.name().equals(type); + } + /** * Add timing and response status data to the audit record * diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java index e8d8ae7a73..da4a48079a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/audit/ControllerAuditAspectTest.java @@ -76,7 +76,8 @@ class ControllerAuditAspectTest { @DisplayName("shouldAudit false proceeds without recording") void skipsWhenShouldAuditFalse() throws Throwable { ProceedingJoinPoint jp = joinPointFor("getEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(false); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(false); when(jp.proceed()).thenReturn("ok"); Object result = aspect.auditGetMethod(jp); @@ -102,7 +103,8 @@ class ControllerAuditAspectTest { @DisplayName("records success outcome and returns result") void recordsSuccess() throws Throwable { ProceedingJoinPoint jp = joinPointFor("postEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(true); when(auditService.captureCurrentPrincipal()).thenReturn("alice"); when(auditService.captureCurrentOrigin()).thenReturn("WEB"); when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) @@ -134,7 +136,8 @@ class ControllerAuditAspectTest { MDC.put("auditPrincipal", "fromMdc"); MDC.put("auditOrigin", "API"); ProceedingJoinPoint jp = joinPointFor("postEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(true); when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) .thenReturn(new HashMap<>()); when(auditService.resolveEventType( @@ -166,7 +169,8 @@ class ControllerAuditAspectTest { @DisplayName("records failure outcome and rethrows") void recordsFailureAndRethrows() throws Throwable { ProceedingJoinPoint jp = joinPointFor("postEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(true); when(auditService.captureCurrentPrincipal()).thenReturn("alice"); when(auditService.captureCurrentOrigin()).thenReturn("WEB"); when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) @@ -204,7 +208,8 @@ class ControllerAuditAspectTest { @DisplayName("annotated method proceeds without double-auditing") void annotatedMethodSkips() throws Throwable { ProceedingJoinPoint jp = joinPointFor("annotatedEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(true); when(auditService.captureCurrentPrincipal()).thenReturn("alice"); when(auditService.captureCurrentOrigin()).thenReturn("WEB"); when(jp.proceed()).thenReturn("ok"); @@ -232,7 +237,8 @@ class ControllerAuditAspectTest { @DisplayName("captures result when enabled and non-UI type") void capturesResult() throws Throwable { ProceedingJoinPoint jp = joinPointFor("postEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(true); when(auditService.captureCurrentPrincipal()).thenReturn("alice"); when(auditService.captureCurrentOrigin()).thenReturn("WEB"); when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) @@ -262,7 +268,8 @@ class ControllerAuditAspectTest { @DisplayName("UI_DATA result is not captured") void uiDataResultSkipped() throws Throwable { ProceedingJoinPoint jp = joinPointFor("getEndpoint"); - when(auditService.shouldAudit(any(Method.class), eq(auditConfig))).thenReturn(true); + when(auditService.shouldAudit(any(), any(Method.class), eq(auditConfig))) + .thenReturn(true); when(auditService.captureCurrentPrincipal()).thenReturn("alice"); when(auditService.captureCurrentOrigin()).thenReturn("WEB"); when(auditService.createBaseAuditData(eq(jp), any(AuditLevel.class))) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java new file mode 100644 index 0000000000..62e80d5280 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditCleanupServiceTest.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.config.AuditConfigurationProperties; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; + +class AuditCleanupServiceTest { + + private final PersistentAuditEventRepository repository = + mock(PersistentAuditEventRepository.class); + + private AuditCleanupService service(boolean runningEE, int retentionDays) { + ApplicationProperties props = new ApplicationProperties(); + var audit = props.getPremium().getEnterpriseFeatures().getAudit(); + audit.setEnabled(true); + audit.setRetentionDays(retentionDays); + return new AuditCleanupService( + repository, new AuditConfigurationProperties(props), runningEE); + } + + @Test + @DisplayName("Enterprise keeps the configured retention, including infinite") + void enterpriseUsesConfigured() { + assertThat(service(true, 90).effectiveRetentionDays()).isEqualTo(90); + assertThat(service(true, 365).effectiveRetentionDays()).isEqualTo(365); + assertThat(service(true, 0).effectiveRetentionDays()).isEqualTo(0); + } + + @Test + @DisplayName("non-Enterprise caps retention at 30 days") + void nonEnterpriseCapsHigherValues() { + assertThat(service(false, 90).effectiveRetentionDays()).isEqualTo(30); + assertThat(service(false, 365).effectiveRetentionDays()).isEqualTo(30); + } + + @Test + @DisplayName("non-Enterprise respects a shorter configured retention") + void nonEnterpriseRespectsLower() { + assertThat(service(false, 14).effectiveRetentionDays()).isEqualTo(14); + assertThat(service(false, 7).effectiveRetentionDays()).isEqualTo(7); + } + + @Test + @DisplayName("non-Enterprise cannot retain forever (<= 0 becomes the cap)") + void nonEnterpriseNoInfinite() { + assertThat(service(false, 0).effectiveRetentionDays()).isEqualTo(30); + assertThat(service(false, -1).effectiveRetentionDays()).isEqualTo(30); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java index c2d14d1307..ab0b3437bb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AuditServiceTest.java @@ -166,6 +166,42 @@ class AuditServiceTest { verify(repository, never()).add(any(AuditEvent.class)); } + @Test + @DisplayName("records document-processing events even without EE (Documents feed)") + void recordsDocumentEventsWithoutEE() { + AuditService nonEe = + new AuditService( + repository, auditConfig, false, pdfDocumentFactory, jwtService); + authenticateAs("alice"); + + nonEe.audit(AuditEventType.PDF_PROCESS, new HashMap<>(), AuditLevel.BASIC); + nonEe.audit(AuditEventType.FILE_OPERATION, new HashMap<>(), AuditLevel.BASIC); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AuditEvent.class); + verify(repository, org.mockito.Mockito.times(2)).add(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(AuditEvent::getType) + .containsExactly( + AuditEventType.PDF_PROCESS.name(), + AuditEventType.FILE_OPERATION.name()); + } + + @Test + @DisplayName("type-aware shouldAudit lets doc events through without EE, blocks others") + void typeAwareShouldAuditWithoutEE() throws Exception { + AuditService nonEe = + new AuditService( + repository, auditConfig, false, pdfDocumentFactory, jwtService); + Method m = Object.class.getMethod("toString"); + + assertThat(nonEe.shouldAudit(AuditEventType.PDF_PROCESS, m, auditConfig)).isTrue(); + assertThat(nonEe.shouldAudit(AuditEventType.FILE_OPERATION, m, auditConfig)).isTrue(); + assertThat(nonEe.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isFalse(); + // With EE, non-doc events at/under the configured level audit too. + assertThat(service.shouldAudit(AuditEventType.USER_LOGIN, m, auditConfig)).isTrue(); + } + @Test @DisplayName("skips when audit disabled") void skipsWhenDisabled() { diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java new file mode 100644 index 0000000000..08cd8d3788 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalDocumentsScopeResolver.java @@ -0,0 +1,46 @@ +package stirling.software.saas.security; + +import java.util.List; +import java.util.Objects; + +import org.springframework.context.annotation.Primary; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.audit.PortalAuditScope; +import stirling.software.proprietary.audit.PortalAuditScopeResolver; +import stirling.software.proprietary.audit.PortalDocumentsScopeResolver; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +/** + * SaaS documents visibility: platform admins see the whole server; every other portal user sees + * their own team's documents (by member email). + */ +@Component +@Primary +@Profile("saas") +@RequiredArgsConstructor +public class SaasPortalDocumentsScopeResolver implements PortalDocumentsScopeResolver { + + private final TeamSecurityExpressions teamSecurity; + private final TeamMembershipRepository membershipRepository; + + @Override + public PortalAuditScope resolve() { + if (PortalAuditScopeResolver.hasAdminAuthority()) { + return PortalAuditScope.server(); + } + Long teamId = teamSecurity.currentUserTeamId(); + if (teamId == null) { + return PortalAuditScope.denied(); + } + List memberEmails = + membershipRepository.findByTeamId(teamId).stream() + .map(m -> m.getUser() == null ? null : m.getUser().getEmail()) + .filter(Objects::nonNull) + .toList(); + return PortalAuditScope.team("team:" + teamId, memberEmails); + } +} diff --git a/frontend/editor/src/core/query/queryClient.ts b/frontend/editor/src/core/query/queryClient.ts index 989ebff8c5..91b11f6e3d 100644 --- a/frontend/editor/src/core/query/queryClient.ts +++ b/frontend/editor/src/core/query/queryClient.ts @@ -5,7 +5,16 @@ import { QueryClient, type DefaultOptions } from "@tanstack/react-query"; export const baseQueryOptions: DefaultOptions["queries"] = { staleTime: 30_000, gcTime: 5 * 60_000, - retry: 1, + // Retry once on transient failures, but never on a 4xx: an auth/forbidden/not-found + // response won't change on a second identical request, so retrying just doubles the + // wait (e.g. a 403 firing twice) before the UI settles. + retry: (failureCount, error) => { + const status = (error as { status?: number } | null)?.status; + if (typeof status === "number" && status >= 400 && status < 500) { + return false; + } + return failureCount < 1; + }, networkMode: "always", refetchOnWindowFocus: false, }; diff --git a/frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts b/frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts new file mode 100644 index 0000000000..79127f669e --- /dev/null +++ b/frontend/editor/src/portal-saas/hooks/useEnterpriseEnabled.ts @@ -0,0 +1,13 @@ +// SaaS enterprise flag: derived from the plan tier (wallet-backed), not a local +// license bean. Shadows the self-hosted app-config version so Enterprise-only +// surfaces (e.g. Infrastructure > Audit) unlock for enterprise-plan tenants. + +import { useTier } from "@portal/contexts/TierContext"; +import type { EnterpriseState } from "@portal-proprietary/hooks/useEnterpriseEnabled"; + +export type { EnterpriseState }; + +export function useEnterpriseEnabled(): EnterpriseState { + const { tier } = useTier(); + return { enabled: tier === "enterprise", loading: false }; +} diff --git a/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx b/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx index f884d7e6f4..205c9b170c 100644 --- a/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx +++ b/frontend/editor/src/portal-saas/views/Infrastructure.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; vi.mock("react-i18next", () => ({ @@ -7,6 +7,12 @@ vi.mock("react-i18next", () => ({ i18n: { changeLanguage: vi.fn() }, }), })); + +const enterprise = { enabled: true, loading: false }; +vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({ + useEnterpriseEnabled: () => enterprise, +})); + // Stub the live tab panels so the test doesn't pull their data dependencies. vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({ ApiKeysTab: () =>

, @@ -18,6 +24,11 @@ vi.mock("@portal/components/infrastructure/AuditTab", () => ({ import { Infrastructure } from "@portal/views/Infrastructure"; describe("Infrastructure (SaaS)", () => { + beforeEach(() => { + enterprise.enabled = true; + enterprise.loading = false; + }); + it("defaults to the live API keys tab and drops the manage-editor button", () => { render(); expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); @@ -41,4 +52,14 @@ describe("Infrastructure (SaaS)", () => { }), ).toBeEnabled(); }); + + it("disables the audit tab for non-enterprise tenants", () => { + enterprise.enabled = false; + render(); + expect( + screen.getByRole("button", { + name: /portal.infrastructure.tabs.audit/, + }), + ).toBeDisabled(); + }); }); diff --git a/frontend/editor/src/portal-saas/views/Infrastructure.tsx b/frontend/editor/src/portal-saas/views/Infrastructure.tsx index f52453e716..66204db202 100644 --- a/frontend/editor/src/portal-saas/views/Infrastructure.tsx +++ b/frontend/editor/src/portal-saas/views/Infrastructure.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Tabs, type TabItem } from "@app/ui"; +import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled"; import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab"; import { AuditTab } from "@portal/components/infrastructure/AuditTab"; import "@portal/views/Infrastructure.css"; @@ -21,6 +22,8 @@ type InfraTab = export function Infrastructure() { const { t } = useTranslation(); const [tab, setTab] = useState("api-keys"); + // Audit is Enterprise-only; disabled (greyed, inert) for non-enterprise tenants. + const auditEnabled = useEnterpriseEnabled().enabled; const comingSoon = (labelKey: string) => ( <> @@ -33,7 +36,11 @@ export function Infrastructure() { const tabs: TabItem[] = [ { key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") }, - { key: "audit", label: t("portal.infrastructure.tabs.audit") }, + { + key: "audit", + label: t("portal.infrastructure.tabs.audit"), + disabled: !auditEnabled, + }, { key: "deployments", label: comingSoon("portal.infrastructure.tabs.deployments"), diff --git a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx index 71ae52ac8b..548934d58a 100644 --- a/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/AuditTab.tsx @@ -29,6 +29,9 @@ import { type AuditFilter = "all" | AuditCategory; +// Enterprise-only: the Infrastructure view disables this tab for non-enterprise +// instances, so this component only ever renders when entitled. The backend still +// scopes the log to admins / team leads (403 -> forbidden state below). export function AuditTab() { const { t } = useTranslation(); const { tier } = useTier(); diff --git a/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts b/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts new file mode 100644 index 0000000000..7b471447fb --- /dev/null +++ b/frontend/editor/src/portal/hooks/useEnterpriseEnabled.ts @@ -0,0 +1,17 @@ +// Enterprise-license flag for the portal, from the backend app-config (`runningEE`). +// Gates Enterprise-only surfaces (e.g. Infrastructure > Audit) so they show a locked +// upsell instead of firing a doomed 403 request. The SaaS build shadows this file to +// derive enterprise from the plan tier (wallet-backed) - see portal-saas. + +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +export interface EnterpriseState { + enabled: boolean; + // True while the flag is still resolving, so callers can hold rather than flash a lock. + loading: boolean; +} + +export function useEnterpriseEnabled(): EnterpriseState { + const { config, loading } = useAppConfig(); + return { enabled: Boolean(config?.runningEE), loading }; +} diff --git a/frontend/editor/src/portal/views/Infrastructure.test.tsx b/frontend/editor/src/portal/views/Infrastructure.test.tsx index cda36caef8..afd23e0ff1 100644 --- a/frontend/editor/src/portal/views/Infrastructure.test.tsx +++ b/frontend/editor/src/portal/views/Infrastructure.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; @@ -14,6 +14,11 @@ vi.mock("@portal/contexts/ViewContext", () => ({ useView: () => ({ setActiveView: vi.fn() }), })); +const enterprise = { enabled: true, loading: false }; +vi.mock("@portal/hooks/useEnterpriseEnabled", () => ({ + useEnterpriseEnabled: () => enterprise, +})); + vi.mock("@portal/components/infrastructure/ApiKeysTab", () => ({ ApiKeysTab: () =>
, })); @@ -42,6 +47,11 @@ function tabButtons() { } describe("Infrastructure view", () => { + beforeEach(() => { + enterprise.enabled = true; + enterprise.loading = false; + }); + it("orders the working tabs first and defaults to API keys", () => { renderView(); @@ -91,4 +101,24 @@ describe("Infrastructure view", () => { expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument(); }); + + it("disables the audit tab (inert, never opens) for non-enterprise users", () => { + enterprise.enabled = false; + renderView(); + + const auditBtn = screen.getByRole("button", { name: `${T}.audit` }); + expect(auditBtn).toBeDisabled(); + + fireEvent.click(auditBtn); + expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument(); + }); + + it("ignores a ?tab=audit deep link when not enterprise", () => { + enterprise.enabled = false; + renderView("/infrastructure?tab=audit"); + + expect(screen.getByTestId("api-keys-tab")).toBeInTheDocument(); + expect(screen.queryByTestId("audit-tab")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/editor/src/portal/views/Infrastructure.tsx b/frontend/editor/src/portal/views/Infrastructure.tsx index 64ef579b55..fa1ff272b9 100644 --- a/frontend/editor/src/portal/views/Infrastructure.tsx +++ b/frontend/editor/src/portal/views/Infrastructure.tsx @@ -1,8 +1,9 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Button, Tabs, type TabItem } from "@app/ui"; import { useView } from "@portal/contexts/ViewContext"; +import { useEnterpriseEnabled } from "@portal/hooks/useEnterpriseEnabled"; import { ApiKeysTab } from "@portal/components/infrastructure/ApiKeysTab"; import { AuditTab } from "@portal/components/infrastructure/AuditTab"; import "@portal/views/Infrastructure.css"; @@ -12,30 +13,39 @@ type InfraTab = "api-keys" | "audit"; /** Shown but inert: no backend behind these screens yet. */ type DisabledInfraTab = "deployments" | "security" | "models" | "storage"; -const ENABLED_TABS: InfraTab[] = ["api-keys", "audit"]; - export function Infrastructure() { const { t } = useTranslation(); const [tab, setTab] = useState("api-keys"); const { setActiveView } = useView(); const [searchParams, setSearchParams] = useSearchParams(); + // Audit is Enterprise-only; disabled (greyed, inert) on non-enterprise instances. + const auditEnabled = useEnterpriseEnabled().enabled; + + const canOpenTab = useCallback( + (key: string) => key === "api-keys" || (key === "audit" && auditEnabled), + [auditEnabled], + ); // Deep-link (?tab=) from elsewhere (e.g. the home visualiser's outcome // cards → audit log): open that tab, then drop the param. useEffect(() => { const requested = searchParams.get("tab"); if (!requested) return; - if ((ENABLED_TABS as string[]).includes(requested)) { + if (canOpenTab(requested)) { setTab(requested as InfraTab); } const next = new URLSearchParams(searchParams); next.delete("tab"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, canOpenTab]); const tabs: TabItem[] = [ { key: "api-keys", label: t("portal.infrastructure.tabs.apiKeys") }, - { key: "audit", label: t("portal.infrastructure.tabs.audit") }, + { + key: "audit", + label: t("portal.infrastructure.tabs.audit"), + disabled: !auditEnabled, + }, { key: "deployments", label: t("portal.infrastructure.tabs.deployments"), @@ -78,7 +88,7 @@ export function Infrastructure() { items={tabs} activeKey={tab} onChange={(key) => { - if ((ENABLED_TABS as string[]).includes(key)) setTab(key as InfraTab); + if (canOpenTab(key)) setTab(key as InfraTab); }} variant="underline" ariaLabel={t("portal.infrastructure.sectionsAriaLabel")} From ddee690c58d84e6493bbe5473e7fc4f4f4d062c5 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:23:55 +0000 Subject: [PATCH 08/10] Exclude Python virtualenvs from the Docker build context (#7658) # Description of Changes Stirling engine docker slimming Exclude Python virtualenvs from the Docker build context Drop unused provider SDKs from the engine dependency set Retry the SQLite WAL switch when workers race on startup Build the engine image in two stages and run it unprivileged Swap voyage SDK for api call removing 200MB bloat Bundle the AI engine in the fat image Publish the AI engine as a standalone image 886MB to 295MB in docker file And Docker fat is only 230MB bigger after adding (since it already has python and some deps) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --- .dockerignore | 8 + .github/workflows/push-docker.yml | 127 ++ docker/embedded/Dockerfile.fat | 36 + engine/Dockerfile | 50 +- engine/pyproject.toml | 5 +- engine/src/stirling/documents/README.md | 13 + engine/src/stirling/documents/embedder.py | 12 +- .../stirling/documents/sqlite_vec_store.py | 24 +- engine/src/stirling/documents/voyage.py | 57 + engine/tests/test_documents.py | 18 + engine/tests/test_voyage_embeddings.py | 205 ++ engine/uv.lock | 1779 +---------------- scripts/init-without-ocr.sh | 43 + 13 files changed, 575 insertions(+), 1802 deletions(-) create mode 100644 engine/src/stirling/documents/voyage.py create mode 100644 engine/tests/test_voyage_embeddings.py diff --git a/.dockerignore b/.dockerignore index 492480e3a0..12326e5bc5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -96,6 +96,14 @@ configs/ __pycache__/ **/__pycache__/ +# Python virtualenvs. Large, platform-specific, and their symlinks break the build. +.venv/ +**/.venv/ +venv/ +**/venv/ +*.egg-info/ +**/*.egg-info/ + # Local env .env .env.* diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index ea379cf7c6..db67c9435d 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -18,6 +18,16 @@ on: required: false type: boolean default: false + build_engine: + description: "Build & push the standalone stirling-engine image." + required: false + type: boolean + default: true + force_engine_rebuild: + description: "Rebuild stirling-engine even if its source hash is unchanged." + required: false + type: boolean + default: false push: branches: - release @@ -50,6 +60,7 @@ jobs: env: RUN_MAIN_APP: ${{ github.event_name != 'workflow_dispatch' || inputs.build_main_app }} RUN_UNOSERVER: ${{ github.event_name != 'workflow_dispatch' || inputs.build_unoserver }} + RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }} steps: - name: Harden Runner uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 @@ -387,3 +398,119 @@ jobs: else echo "Warning: COSIGN_PRIVATE_KEY not set, skipping unoserver image signing" fi + + # Standalone AI engine image, same shape as the unoserver image above. + - name: Compute engine image source hash + id: engineHash + if: env.RUN_ENGINE == 'true' + run: | + set -eu + hash=$( { cat engine/Dockerfile engine/pyproject.toml engine/uv.lock engine/.env; \ + find engine/src -type f -print0 | sort -z | xargs -0 cat; } \ + | sha256sum | cut -d' ' -f1) + echo "hash=${hash}" >> "$GITHUB_OUTPUT" + echo "Engine source hash: ${hash}" + + - name: Decide whether to publish engine image + id: engineDecision + if: env.RUN_ENGINE == 'true' + env: + ENGINE_VERSION: ${{ steps.versionNumber.outputs.versionNumber }} + ENGINE_HASH: ${{ steps.engineHash.outputs.hash }} + ENGINE_IMAGE: ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-engine + ENGINE_HASH_ANNOTATION: org.stirlingpdf.engine-source-hash + FORCE_REBUILD: ${{ inputs.force_engine_rebuild }} + GH_REF: ${{ github.ref }} + EVENT_NAME: ${{ github.event_name }} + run: | + set -eu + mode="skip" + tags="" + + read_published_hash() { + local ref="$1" + docker buildx imagetools inspect "$ref" --raw 2>/dev/null \ + | jq -r --arg key "$ENGINE_HASH_ANNOTATION" \ + '.annotations[$key] // empty' \ + 2>/dev/null || true + } + + # Manual dispatch from any branch routes to the :alpha publish path. + EFFECTIVE_REF="$GH_REF" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + EFFECTIVE_REF="refs/heads/testMain" + fi + + case "$EFFECTIVE_REF" in + refs/heads/release) + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — building stable regardless" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + elif docker manifest inspect "${ENGINE_IMAGE}:${ENGINE_VERSION}" >/dev/null 2>&1; then + echo "stirling-engine:${ENGINE_VERSION} already on GHCR — skipping" + else + echo "stirling-engine:${ENGINE_VERSION} is new — will publish" + mode="stable" + tags="${ENGINE_IMAGE}:${ENGINE_VERSION},${ENGINE_IMAGE}:latest" + fi + ;; + refs/heads/main|refs/heads/testMain) + published_hash=$(read_published_hash "${ENGINE_IMAGE}:alpha") + if [ "${FORCE_REBUILD}" = "true" ]; then + echo "force_engine_rebuild=true — rebuilding :alpha regardless" + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + elif [ -n "$published_hash" ] && [ "$published_hash" = "$ENGINE_HASH" ]; then + echo "Published :alpha source hash matches (${published_hash}) — skipping" + else + if [ -z "$published_hash" ]; then + echo ":alpha has no source-hash annotation (first publish) — will publish" + else + echo "Source hash changed (was ${published_hash}, now ${ENGINE_HASH}) — will publish" + fi + mode="alpha" + tags="${ENGINE_IMAGE}:alpha" + fi + ;; + *) + echo "Branch ${GH_REF} does not publish engine image" + ;; + esac + echo "mode=${mode}" >> "$GITHUB_OUTPUT" + echo "tags=${tags}" >> "$GITHUB_OUTPUT" + + - name: Build and push engine image + id: build-push-engine + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode != 'skip' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./engine/Dockerfile + push: true + cache-from: type=gha,scope=stirling-engine + cache-to: type=gha,mode=max,scope=stirling-engine + tags: ${{ steps.engineDecision.outputs.tags }} + # Manifest annotation read by the decision step above to detect drift. + annotations: | + index:org.stirlingpdf.engine-source-hash=${{ steps.engineHash.outputs.hash }} + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Sign engine image + if: env.RUN_ENGINE == 'true' && steps.engineDecision.outputs.mode == 'stable' + env: + DIGEST: ${{ steps.build-push-engine.outputs.digest }} + TAGS: ${{ steps.engineDecision.outputs.tags }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ]; then + echo "$TAGS" | tr ',' '\n' | while read -r tag; do + cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}" + done + else + echo "Warning: COSIGN_PRIVATE_KEY not set, skipping engine image signing" + fi diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 542bdec981..cda78127af 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -67,6 +67,30 @@ COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers +# Stage 2b: AI engine. Built at its final path so the venv resolves after the copy, on uv's +# managed CPython because the runtime base ships Python 3.12. +FROM ghcr.io/astral-sh/uv:bookworm-slim@sha256:22334efe746f1b69217d455049b484d7b8cacfb2d5f42555580b62415a98e0a3 AS engine-build +ENV UV_PYTHON_INSTALL_DIR=/opt/stirling-engine/python +WORKDIR /opt/stirling-engine +COPY engine/pyproject.toml engine/uv.lock ./ +# One layer: trimming in a second RUN would cache the untrimmed copy too, and this build +# exports every layer to a GHA cache that is capped repo-wide. Trimming saves ~20MB. +RUN --mount=type=cache,target=/root/.cache/uv \ + set -eux; \ + apt-get update && apt-get install -y --no-install-recommends binutils; \ + uv python install 3.13; \ + uv sync --frozen --no-dev --no-install-project --group engine --python-preference only-managed; \ + P="$(ls -d /opt/stirling-engine/python/cpython-*)"; \ + rm -rf "$P/share" "$P/include" \ + "$P/lib/python3.13/idlelib" "$P/lib/python3.13/tkinter" \ + "$P/lib/python3.13/ensurepip" "$P/lib/python3.13/pydoc_data" \ + "$P/lib/python3.13/test" "$P/lib/python3.13/lib2to3"; \ + find /opt/stirling-engine -name '__pycache__' -type d -prune -exec rm -rf {} + ; \ + find /opt/stirling-engine \( -name '*.so' -o -name '*.so.*' \) -print0 \ + | xargs -0 -r strip --strip-unneeded 2>/dev/null || true; \ + apt-get purge -y binutils; apt-get autoremove -y; rm -rf /var/lib/apt/lists/* + + # Stage 3: Final runtime image on top of pre-built base FROM ${BASE_IMAGE} @@ -84,6 +108,12 @@ COPY --link --from=app-build --chown=1000:1000 \ /app/build/libs/restart-helper.jar /restart-helper.jar COPY --link --chown=1000:1000 scripts/ /scripts/ +# init-without-ocr.sh starts the engine when this directory exists, so other images are unaffected. +COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/python /opt/stirling-engine/python +COPY --link --from=engine-build --chown=1000:1000 /opt/stirling-engine/.venv /opt/stirling-engine/.venv +COPY --link --chown=1000:1000 engine/.env /opt/stirling-engine/.env +COPY --link --chown=1000:1000 engine/src/ /opt/stirling-engine/src/ + # Fonts go to system dir, root ownership is correct (world-readable) COPY app/core/src/main/resources/static/fonts/*.ttf /usr/share/fonts/truetype/ @@ -97,6 +127,8 @@ RUN set -eux; \ ln -s /storage /app/storage; \ chown -h stirlingpdfuser:stirlingpdfgroup /app/logs /app/configs /app/customFiles /app/pipeline /app/storage; \ chown stirlingpdfuser:stirlingpdfgroup /app; \ + mkdir -p /opt/stirling-engine/data; \ + chown -R stirlingpdfuser:stirlingpdfgroup /opt/stirling-engine/data; \ chmod 750 /tmp/stirling-pdf; \ chmod 750 /tmp/stirling-pdf/heap_dumps; \ fc-cache -f @@ -116,6 +148,10 @@ ENV VERSION_TAG=$VERSION_TAG \ PGID=1000 \ UMASK=022 \ FAT_DOCKER=true \ + AIENGINE_ENABLED=true \ + STIRLING_ENGINE_HOME=/opt/stirling-engine \ + STIRLING_ENGINE_PORT=5001 \ + STIRLING_ENGINE_WORKERS=2 \ INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \ STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ TMPDIR=/tmp/stirling-pdf \ diff --git a/engine/Dockerfile b/engine/Dockerfile index 17a917761b..575fa82eff 100644 --- a/engine/Dockerfile +++ b/engine/Dockerfile @@ -1,35 +1,43 @@ # syntax=docker/dockerfile:1.5 -FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca -ARG TASK_VERSION=3.52.0 -RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates \ - && ARCH=$(dpkg --print-architecture) \ - && curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \ - && dpkg -i /tmp/task.deb \ - && rm /tmp/task.deb \ - && rm -rf /var/lib/apt/lists/* +# uv resolves the venv here so its ~52MB binary stays out of the runtime image. +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim@sha256:531f855bda2c73cd6ef67d56b733b357cea384185b3022bd09f05e002cd144ca AS builder -# Source under /app/engine/ to match root Taskfile's `includes.engine.dir: engine`. WORKDIR /app/engine -COPY engine/pyproject.toml engine/uv.lock engine/.env ./ -COPY engine/scripts/ ./scripts/ +COPY engine/pyproject.toml engine/uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --frozen --no-dev --group engine + uv sync --frozen --no-dev --no-install-project --group engine -COPY engine/src/ ./src/ -WORKDIR /app -COPY Taskfile.yml ./ -COPY .taskfiles/ ./.taskfiles/ +FROM python:3.13-slim-bookworm@sha256:00faa2debb87529f9f0764e9491d8ba400a3678976616c3bd7cb193745ac20d1 AS runtime + +# Created before the COPYs so they land owned; a later chown -R duplicates the venv layer. +RUN set -eux; \ + groupadd --system --gid 1000 stirling; \ + useradd --system --uid 1000 --gid 1000 --home /app/engine stirling; \ + mkdir -p /app/engine/data; \ + chown stirling:stirling /app/engine /app/engine/data + +WORKDIR /app/engine +COPY --from=builder --chown=stirling:stirling /app/engine/.venv ./.venv +# settings.py resolves ENGINE_ROOT to /app/engine, so .env must sit here. +COPY --chown=stirling:stirling engine/.env ./ +COPY --chown=stirling:stirling engine/src/ ./src/ ENV PATH="/app/engine/.venv/bin:$PATH" ENV PYTHONUNBUFFERED=1 ENV STIRLING_ENGINE_WORKERS=4 -# Container runs on a fixed port; skip the host-only free-port probe (its script -# is not shipped in the image). engine:run honours these. -ENV ENGINE_PORT_PROBE=false ENV STIRLING_ENGINE_PORT=5001 +# Fail closed: without a secret the document routes trust caller-supplied X-User-Id. +# Set STIRLING_ENGINE_SHARED_SECRET (the backend sends it as X-Engine-Auth), or false to opt out. +ENV STIRLING_ENGINE_REQUIRE_AUTH=true + +# `stirling` resolves from the working directory. +WORKDIR /app/engine/src +USER stirling EXPOSE 5001 -CMD ["task", "engine:run"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD ["python", "-c", "import os,sys,urllib.request; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('STIRLING_ENGINE_PORT','5001'), timeout=4).status==200 else 1)"] + +CMD ["sh", "-c", "exec uvicorn stirling.api.app:app --host 0.0.0.0 --port ${STIRLING_ENGINE_PORT:-5001} --workers ${STIRLING_ENGINE_WORKERS:-4}"] diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 0e4ffb1586..23c3bc078c 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -16,8 +16,9 @@ engine = [ "psycopg[binary,pool]>=3.3.4", "pydantic>=2.13.4", # <2 cap: 1.99.0 patches CVE-2026-46678; 2.0 is an untested major migration. - "pydantic-ai>=1.107.2,<2.0.0", - "pydantic-ai-slim[voyageai]>=1.107.2,<2.0.0", + # Explicit extras: the `pydantic-ai` meta-package pulls all 20 providers (~230MB). + # No `voyageai` extra either; stirling.documents.voyage speaks its API directly. + "pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0", "pydantic-settings>=2.15.0", "python-dotenv>=1.2.2", "sqlite-vec>=0.1.9", diff --git a/engine/src/stirling/documents/README.md b/engine/src/stirling/documents/README.md index 6848d8a101..f73341f8b6 100644 --- a/engine/src/stirling/documents/README.md +++ b/engine/src/stirling/documents/README.md @@ -70,6 +70,19 @@ Provider credentials (and any local overrides) go in the uncommitted VOYAGE_API_KEY=your-key ``` +### Embedding providers + +`STIRLING_RAG_EMBEDDING_MODEL` is a `provider:model` string. Any OpenAI-compatible +`/v1/embeddings` endpoint (vLLM, Ollama, TEI, llama.cpp) works by pointing a base URL +at it. Note `OPENAI_BASE_URL` is global and also redirects chat completions; push +`provider`/`api_key`/`base_url` through admin AI settings to move embeddings only. +Ollama reads `OLLAMA_BASE_URL`, and omitting it fails the first embed call, not startup. + +``` +STIRLING_RAG_EMBEDDING_MODEL=ollama:nomic-embed-text +OLLAMA_BASE_URL=http://ollama:11434/v1 +``` + ## Backends **`sqlite`** - Embedded sqlite-vec. Single `.db` file, zero ops. Ideal for dev diff --git a/engine/src/stirling/documents/embedder.py b/engine/src/stirling/documents/embedder.py index c92a448149..4ab0508a74 100644 --- a/engine/src/stirling/documents/embedder.py +++ b/engine/src/stirling/documents/embedder.py @@ -6,6 +6,7 @@ from pydantic_ai.providers.openai import OpenAIProvider from stirling.documents.chunker import chunk_text from stirling.documents.store import Document +from stirling.documents.voyage import build_voyage_model # Keep each upstream embed request under every major provider's per-call limit while # still batching large enough that a book-sized document ingests in a reasonable number @@ -14,6 +15,9 @@ from stirling.documents.store import Document DEFAULT_EMBED_BATCH_SIZE = 256 +VOYAGE_PROVIDER = "voyageai" + + def _build_embedder( model_name: str, *, @@ -23,11 +27,17 @@ def _build_embedder( ) -> Embedder: """Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form.""" if not provider and not api_key and not base_url: + # Env form is a "provider:model" string; Voyage needs the SDK-free adapter. + env_provider, sep, env_model = model_name.partition(":") + if sep and env_provider.lower() == VOYAGE_PROVIDER: + return Embedder(build_voyage_model(env_model)) return Embedder(model_name) provider_name = (provider or "").lower() key = api_key or None - if provider_name in ("voyageai", "openai"): + if provider_name == VOYAGE_PROVIDER: + return Embedder(build_voyage_model(model_name, api_key=key, base_url=base_url or None)) + if provider_name == "openai": return Embedder(f"{provider_name}:{model_name}") if provider_name in ("ollama", "custom"): openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama") diff --git a/engine/src/stirling/documents/sqlite_vec_store.py b/engine/src/stirling/documents/sqlite_vec_store.py index 5f909a4bcd..4f5bac7005 100644 --- a/engine/src/stirling/documents/sqlite_vec_store.py +++ b/engine/src/stirling/documents/sqlite_vec_store.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio import json +import logging import math import re import sqlite3 +import time from datetime import UTC, datetime from pathlib import Path @@ -19,11 +21,31 @@ _READ_PERMISSION = "read" # write lock. With multiple worker processes opening the same file, they collide on # startup schema-init and get "database is locked". Wait for the lock instead. _BUSY_TIMEOUT_MS = 5000 +# journal_mode answers SQLITE_BUSY without consulting the busy handler, so it needs its own retry. +_WAL_SWITCH_ATTEMPTS = 10 +_WAL_RETRY_DELAY_S = 0.1 # sqlite stores TIMESTAMP as TEXT. We normalise to UTC ISO 8601 ``YYYY-MM-DD HH:MM:SS`` # so lexicographic comparison against ``datetime('now')`` matches chronological order. _SQLITE_DATETIME_FMT = "%Y-%m-%d %H:%M:%S" +logger = logging.getLogger(__name__) + + +def _enable_wal(conn: sqlite3.Connection) -> None: + """Switch the connection to WAL, tolerating workers racing to do the same.""" + for _ in range(_WAL_SWITCH_ATTEMPTS): + try: + conn.execute("PRAGMA journal_mode=WAL") + return + except sqlite3.OperationalError: + row = conn.execute("PRAGMA journal_mode").fetchone() + if row is not None and str(row[0]).lower() == "wal": + return # another worker won the race and already switched it + time.sleep(_WAL_RETRY_DELAY_S) + logger.warning("Could not switch the document store to WAL; continuing on the default journal mode.") + + def _to_sqlite_utc(dt: datetime | None) -> str | None: if dt is None: return None @@ -58,7 +80,7 @@ class SqliteVecStore(DocumentStore): if self._db_path is not None: # Set before the WAL switch below: that pragma also takes the lock. conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}") - conn.execute("PRAGMA journal_mode=WAL") + _enable_wal(conn) self._conn = conn self._lock = asyncio.Lock() diff --git a/engine/src/stirling/documents/voyage.py b/engine/src/stirling/documents/voyage.py new file mode 100644 index 0000000000..e308f32ec9 --- /dev/null +++ b/engine/src/stirling/documents/voyage.py @@ -0,0 +1,57 @@ +"""VoyageAI embeddings over its OpenAI-shaped REST API. + +The `voyageai` SDK pulls PIL, numpy, tokenizers and langchain at import for multimodal, +chunking and local-inference features the engine never uses (~207MB). +""" + +from __future__ import annotations + +import os +from collections.abc import Sequence + +from pydantic_ai.embeddings import EmbeddingResult, EmbeddingSettings +from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel +from pydantic_ai.embeddings.result import EmbedInputType +from pydantic_ai.providers.openai import OpenAIProvider + +VOYAGE_BASE_URL = "https://api.voyageai.com/v1" +VOYAGE_API_KEY_ENV = "VOYAGE_API_KEY" + +# Keeps a keyless engine bootable, and stops the client falling back to OPENAI_API_KEY. +_MISSING_API_KEY = "stirling-voyage-api-key-not-configured" + + +class VoyageEmbeddingModel(OpenAIEmbeddingModel): + """Voyage embeddings spoken over the OpenAI wire format.""" + + async def embed( + self, + inputs: str | Sequence[str], + *, + input_type: EmbedInputType, + settings: EmbeddingSettings | None = None, + ) -> EmbeddingResult: + """Embed `inputs`, forwarding Voyage's `input_type` that the OpenAI model drops.""" + if self._client.api_key == _MISSING_API_KEY: + raise ValueError( + f"VoyageAI embeddings need an API key: set {VOYAGE_API_KEY_ENV} or push one via admin AI settings." + ) + merged: EmbeddingSettings = {**(settings or {})} + # extra_body is declared `object`, so narrow rather than assume a mapping. + current = merged.get("extra_body") + extra_body: dict[str, object] = dict(current) if isinstance(current, dict) else {} + extra_body.setdefault("input_type", input_type) + merged["extra_body"] = extra_body + return await super().embed(inputs, input_type=input_type, settings=merged) + + +def build_voyage_model( + model_name: str, + *, + api_key: str | None = None, + base_url: str | None = None, +) -> VoyageEmbeddingModel: + """Build a Voyage embedding model; a missing key only fails once an embed is attempted.""" + key = api_key or os.environ.get(VOYAGE_API_KEY_ENV) or _MISSING_API_KEY + provider = OpenAIProvider(base_url=base_url or VOYAGE_BASE_URL, api_key=key) + return VoyageEmbeddingModel(model_name, provider=provider) diff --git a/engine/tests/test_documents.py b/engine/tests/test_documents.py index 7da5c34804..bee93a134e 100644 --- a/engine/tests/test_documents.py +++ b/engine/tests/test_documents.py @@ -1,5 +1,7 @@ from __future__ import annotations +from pathlib import Path + import pytest from stirling.contracts import PageText @@ -647,3 +649,19 @@ def _dummy_tool_def() -> object: """Sentinel passed to ``_prepare_search_knowledge``. The callback only inspects ``_search_count``; it doesn't read anything off the tool_def or context.""" return object() + + +# concurrent store startup + + +def test_many_stores_open_the_same_file_without_locking_out(tmp_path: Path) -> None: + """Workers all construct a store against one file on boot; the WAL switch races.""" + import concurrent.futures + + db_path = tmp_path / "rag.db" + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool: + stores = list(pool.map(lambda _: SqliteVecStore(db_path), range(8))) + + assert len(stores) == 8 + mode = stores[0]._conn.execute("PRAGMA journal_mode").fetchone()[0] + assert str(mode).lower() == "wal" diff --git a/engine/tests/test_voyage_embeddings.py b/engine/tests/test_voyage_embeddings.py new file mode 100644 index 0000000000..85e267bc73 --- /dev/null +++ b/engine/tests/test_voyage_embeddings.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import json +import math +import os +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any + +import httpx +import pytest +from pydantic_ai import Embedder +from pydantic_ai.providers.openai import OpenAIProvider + +from stirling.documents.embedder import _build_embedder +from stirling.documents.voyage import VOYAGE_BASE_URL, VoyageEmbeddingModel, build_voyage_model + +# Voyage's documented response body: OpenAI's shape, minus prompt_tokens. +VOYAGE_RESPONSE = { + "object": "list", + "data": [ + {"object": "embedding", "embedding": [0.1, 0.2, 0.3], "index": 0}, + {"object": "embedding", "embedding": [0.4, 0.5, 0.6], "index": 1}, + ], + "model": "voyage-4", + "usage": {"total_tokens": 7}, +} + + +@dataclass +class SentRequest: + url: str + auth: str | None + body: dict[str, Any] + + +def _recording_model(sent: list[SentRequest]) -> VoyageEmbeddingModel: + + def handler(request: httpx.Request) -> httpx.Response: + sent.append( + SentRequest( + url=str(request.url), + auth=request.headers.get("authorization"), + body=json.loads(request.content), + ) + ) + return httpx.Response(200, json=VOYAGE_RESPONSE) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + provider = OpenAIProvider(base_url=VOYAGE_BASE_URL, api_key="pa-test-key", http_client=client) + return VoyageEmbeddingModel("voyage-4", provider=provider) + + +@pytest.mark.anyio +async def test_posts_to_voyage_embeddings_endpoint_with_bearer_auth() -> None: + sent: list[SentRequest] = [] + await Embedder(_recording_model(sent)).embed_documents(["alpha", "beta"]) + + assert sent[0].url == f"{VOYAGE_BASE_URL}/embeddings" + assert sent[0].auth == "Bearer pa-test-key" + assert sent[0].body["model"] == "voyage-4" + assert sent[0].body["input"] == ["alpha", "beta"] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("call", "expected"), + [("embed_query", "query"), ("embed_documents", "document")], +) +async def test_forwards_voyage_input_type(call: str, expected: str) -> None: + """The stock OpenAI model drops this field; Voyage needs it.""" + sent: list[SentRequest] = [] + embedder = Embedder(_recording_model(sent)) + await getattr(embedder, call)(["text"]) + + assert sent[0].body["input_type"] == expected + + +@pytest.mark.anyio +async def test_caller_settings_win_over_the_default_input_type() -> None: + sent: list[SentRequest] = [] + await Embedder(_recording_model(sent)).embed_documents( + ["text"], settings={"extra_body": {"input_type": "query", "output_dimension": 512}} + ) + + assert sent[0].body["input_type"] == "query" + assert sent[0].body["output_dimension"] == 512 + + +@pytest.mark.anyio +async def test_parses_voyage_response_into_embeddings() -> None: + result = await Embedder(_recording_model([])).embed_documents(["alpha", "beta"]) + + assert result.embeddings == [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] + + +def test_build_voyage_model_reads_the_api_key_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key") + + assert build_voyage_model("voyage-4").model_name == "voyage-4" + + +def test_build_voyage_model_without_a_key_still_constructs(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VOYAGE_API_KEY", raising=False) + + assert build_voyage_model("voyage-4").model_name == "voyage-4" + + +@pytest.mark.anyio +async def test_embedding_without_a_key_fails_with_a_clear_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VOYAGE_API_KEY", raising=False) + model = build_voyage_model("voyage-4") + + with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"): + await Embedder(model).embed_documents(["text"]) + + +@pytest.mark.anyio +async def test_an_openai_key_is_never_sent_to_voyage(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VOYAGE_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-secret") + model = build_voyage_model("voyage-4") + + with pytest.raises(ValueError, match="VoyageAI embeddings need an API key"): + await Embedder(model).embed_documents(["text"]) + + +def test_env_form_routes_voyageai_through_the_adapter(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VOYAGE_API_KEY", "pa-env-key") + + embedder = _build_embedder("voyageai:voyage-4") + + assert isinstance(embedder.model, VoyageEmbeddingModel) + assert embedder.model.model_name == "voyage-4" + + +def test_config_push_form_routes_voyageai_through_the_adapter() -> None: + embedder = _build_embedder("voyage-4", provider="voyageai", api_key="pa-pushed-key") + + assert isinstance(embedder.model, VoyageEmbeddingModel) + + +def test_the_voyageai_sdk_is_not_installed() -> None: + """Guards the ~207MB the SDK would add back.""" + with pytest.raises(ImportError): + __import__("voyageai") + + +# Live checks, skipped unless VOYAGE_API_KEY is set so CI stays offline. +live_only = pytest.mark.skipif( + not os.environ.get("VOYAGE_API_KEY"), + reason="set VOYAGE_API_KEY to run the live VoyageAI checks", +) + + +def _cosine(a: Sequence[float], b: Sequence[float]) -> float: + dot = sum(x * y for x, y in zip(a, b, strict=True)) + return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))) + + +@live_only +@pytest.mark.anyio +async def test_live_voyage_returns_usable_embeddings() -> None: + result = await Embedder(build_voyage_model("voyage-4")).embed_documents(["alpha", "beta"]) + + assert len(result.embeddings) == 2 + assert len(result.embeddings[0]) == 1024 + + +@live_only +@pytest.mark.anyio +async def test_live_voyage_honours_input_type_server_side() -> None: + """Voyage embeds the same text differently per input_type.""" + embedder = Embedder(build_voyage_model("voyage-4")) + text = "How do I combine two PDFs?" + + as_query = await embedder.embed_query(text) + as_document = await embedder.embed_documents([text]) + + assert _cosine(as_query.embeddings[0], as_document.embeddings[0]) < 0.999 + + +@live_only +@pytest.mark.anyio +async def test_live_voyage_ranks_the_relevant_document_first() -> None: + embedder = Embedder(build_voyage_model("voyage-4")) + docs = await embedder.embed_documents( + ["Stirling PDF merges and splits PDF files.", "The capital of France is Paris."] + ) + query = await embedder.embed_query("How do I combine two PDFs?") + + relevant = _cosine(query.embeddings[0], docs.embeddings[0]) + irrelevant = _cosine(query.embeddings[0], docs.embeddings[1]) + assert relevant > irrelevant + + +@live_only +@pytest.mark.anyio +async def test_live_voyage_accepts_voyage_only_parameters() -> None: + """output_dimension has no OpenAI equivalent, so this proves extra_body lands.""" + result = await Embedder(build_voyage_model("voyage-4")).embed_documents( + ["dimension test"], settings={"extra_body": {"output_dimension": 256}} + ) + + assert len(result.embeddings[0]) == 256 diff --git a/engine/uv.lock b/engine/uv.lock index bb5b812fce..a3286b100d 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -2,100 +2,6 @@ version = 1 revision = 3 requires-python = "==3.13.*" -[[package]] -name = "ag-ui-protocol" -version = "0.1.19" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, -] - -[[package]] -name = "aiofile" -version = "3.12.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "caio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/31/edb06aabd8f8f0b56d659f30800795f40b93cba96be946ce179f6931e3a5/aiofile-3.12.3.tar.gz", hash = "sha256:caa6aa746b5e47e2165f7abd741b6415e49cf4d44fddc0f61844612cc3924d41", size = 21600, upload-time = "2026-08-04T22:59:27.171Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/79/6e45e778c4c3cab39e0937b007b720c15f76c50c6453d153282d0fcc3588/aiofile-3.12.3-py3-none-any.whl", hash = "sha256:5c1bcc9e929c50834608e8cc1a4cc1d7503eb60c15a535b779fd39e2f372c017", size = 22122, upload-time = "2026-08-04T22:59:25.838Z" }, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, -] - -[[package]] -name = "aiolimiter" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "annotated-doc" version = "0.0.5" @@ -163,19 +69,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "authlib" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "joserfc" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, -] - [[package]] name = "backoff" version = "2.2.1" @@ -185,15 +78,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] -[[package]] -name = "beartype" -version = "0.22.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -258,34 +142,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] -[[package]] -name = "boto3" -version = "1.43.67" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, - { name = "jmespath" }, - { name = "s3transfer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ad/bf/ef5de9b55523bc2141d072fbe6614627088e3e6f97b47850216e99de6a1c/boto3-1.43.67.tar.gz", hash = "sha256:75fe983b70d39cfdc274dc51f9bb02b8a0a104bdad4fa073c1af85a35e707c91", size = 112665, upload-time = "2026-08-07T19:30:20.418Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/0f/b41f8968452dc6bf3b83f15f5e9f76a27fd93e246906aeb2e0555f494375/boto3-1.43.67-py3-none-any.whl", hash = "sha256:082cf9df068168cb44028a1703822374c1eb7e48fa49470d7e8a76f0c977d0bc", size = 140025, upload-time = "2026-08-07T19:30:18.49Z" }, -] - -[[package]] -name = "botocore" -version = "1.43.67" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/53/1c/3a75deae60e36bd0ee5c27d040384756b2ea1c90bd7c8c9658335a18b4f5/botocore-1.43.67.tar.gz", hash = "sha256:6fe5cfa0c8676ba809efe505b618ec00f30d1af2d014bf316a7aa4ee86accb20", size = 15889514, upload-time = "2026-08-07T19:30:15.638Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/be/38af8e96f3d200c9d34eab8e9f69a4468388ed6030ea04ff012974e5af5a/botocore-1.43.67-py3-none-any.whl", hash = "sha256:48ab8e9fac26fbc2a700d57010251003e6a5f731cf74d8540fb796bc8f3fc0ef", size = 15575924, upload-time = "2026-08-07T19:30:12.116Z" }, -] - [[package]] name = "brotli" version = "1.2.0" @@ -320,30 +176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, ] -[[package]] -name = "cachetools" -version = "7.1.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, -] - -[[package]] -name = "caio" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/c8/82b3c760141a1076408164b03e8789b51809add6aecd48aa9d7651cf6b59/caio-0.12.2.tar.gz", hash = "sha256:87a67c0dccc60e432888bd532ec504b66e124a5d8b391aab894583b55abd39ea", size = 80927, upload-time = "2026-08-04T14:43:33.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/9b/31f0b49a2542ffa2f9d6140267e2b568e722a1feeb05cfbffea97666c62b/caio-0.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40ebea9ebe3a3a66ae85fa00d4112d163654a33c82dcf9b26a99f7d30de13317", size = 84656, upload-time = "2026-08-04T14:43:10.513Z" }, - { url = "https://files.pythonhosted.org/packages/99/bc/62568d688af9712a34fe3f958d7a98c53bb2017e263260cd5deae67a90e9/caio-0.12.2-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:6003ec389a68d5ec8f089df82b2dc8915293dd630a4d11322d7e3455045981fd", size = 198443, upload-time = "2026-08-04T14:43:11.767Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e4/5ed627860285612e5307f06c109913c5918c947fbc223b55599e484c64b0/caio-0.12.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:eee9376d0e2af25b6defc5bce39f6efa90521c803aaf12eba931bd898a397cfc", size = 196356, upload-time = "2026-08-04T14:43:13.206Z" }, - { url = "https://files.pythonhosted.org/packages/81/e2/2a8cfc6ba3ef3f19e7c778e9fb6f98600f0971cca78bbdfc23a413a66349/caio-0.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:78e3ccafc98e009fcb00a97ad441585551e52c0ae7ecc50427a3ccd9b11502fd", size = 195893, upload-time = "2026-08-04T14:43:14.649Z" }, - { url = "https://files.pythonhosted.org/packages/d1/87/77c40fb2301d0b5bb27c2e79ae42fce718ed75396d5fe3e1c09d8e1400b1/caio-0.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f2355db8917f5a0f3638bf332fe0d87549c80e978fca01db84a8a14b9df56a05", size = 195969, upload-time = "2026-08-04T14:43:15.946Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b5/0ceca97eb546fe6bbace3399c8b11dfc503efcc7509d708a7a3f09ab50e9/caio-0.12.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8054cba5e7ee623bea34946e2b59eb7c7c2be8872d0a5d12215d6ff564938d5f", size = 78621, upload-time = "2026-08-04T14:43:17.316Z" }, - { url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" }, -] - [[package]] name = "certifi" version = "2026.7.22" @@ -421,25 +253,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/bf/bdb951d34eb169140b546f44be9ec4525d1acefb9eb5572071f5492b19fc/codespell-2.4.3-py3-none-any.whl", hash = "sha256:af2505b335e8573dbd2d384d1c4ef498f4006f4ba2d6fceca01e55b91f52628a", size = 340736, upload-time = "2026-07-15T11:51:52.925Z" }, ] -[[package]] -name = "cohere" -version = "7.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastavro" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "pydantic-core" }, - { name = "requests" }, - { name = "tokenizers" }, - { name = "types-requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/16/62ba386903ea854a2fce2cc2f959b9e0c8fa1d3e5699a3a523a6c691ffc9/cohere-7.0.8.tar.gz", hash = "sha256:5d9986b9b13cfe5590021474540648ff3c9e9ce4bfddc3d7f8e31acf1898c4d7", size = 213501, upload-time = "2026-07-23T19:31:41.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/52/255cefc27e058853b8750fcb22c549fd52684214d4fc1e4169ab23320644/cohere-7.0.8-py3-none-any.whl", hash = "sha256:a4fe9b9e148cb277ee152009d3566434b055db9d41fc7433abda78add22632ab", size = 357010, upload-time = "2026-07-23T19:31:39.432Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -517,21 +330,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/2a/894aded5804c76cf148965721cb57fed0d923ddb2747a77c9147f20d58a9/cucumber_tag_expressions-11.0.1-py3-none-any.whl", hash = "sha256:8ee5433a3b1ad16ca607c905fa3bb6d85d57f087ba119b14ea5e82cd35ea98c5", size = 9757, upload-time = "2026-08-05T20:33:19.95Z" }, ] -[[package]] -name = "cyclopts" -version = "4.22.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "docstring-parser" }, - { name = "rich" }, - { name = "rich-rst" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/be/05/689617b7e86503417c172f577d791524cb13b9697303d5d44409a971ba10/cyclopts-4.22.5.tar.gz", hash = "sha256:94044506317462cad90fb01a917dadce1f48a0915ba3605dc8d178dea1229e24", size = 195144, upload-time = "2026-08-04T13:53:00.303Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/58/bcab9c33fb7a25a1f5970f357c5b19729bc81d50615d2f737b20c4255909/cyclopts-4.22.5-py3-none-any.whl", hash = "sha256:cf9ce285836053d156730ea4ea0ad0c75cf63beb3f3d8edf222a795bc57666ab", size = 234557, upload-time = "2026-08-04T13:52:58.509Z" }, -] - [[package]] name = "datamodel-code-generator" version = "0.64.0" @@ -587,15 +385,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "dnspython" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, -] - [[package]] name = "docstring-parser" version = "0.18.0" @@ -605,19 +394,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "email-validator" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "dnspython" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, -] - [[package]] name = "engine" version = "0.1.0" @@ -643,8 +419,7 @@ engine = [ { name = "posthog" }, { name = "psycopg", extra = ["binary", "pool"] }, { name = "pydantic" }, - { name = "pydantic-ai" }, - { name = "pydantic-ai-slim", extra = ["voyageai"] }, + { name = "pydantic-ai-slim", extra = ["anthropic", "openai"] }, { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "sqlite-vec" }, @@ -702,8 +477,7 @@ engine = [ { name = "posthog", specifier = ">=7.38.3" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.4" }, { name = "pydantic", specifier = ">=2.13.4" }, - { name = "pydantic-ai", specifier = ">=1.107.2,<2.0.0" }, - { name = "pydantic-ai-slim", extras = ["voyageai"], specifier = ">=1.107.2,<2.0.0" }, + { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.107.2,<2.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, { name = "sqlite-vec", specifier = ">=0.1.9" }, @@ -737,33 +511,6 @@ tools = [ ] updater-signatures = [{ name = "cryptography", specifier = ">=50.0.0" }] -[[package]] -name = "eval-type-backport" -version = "0.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/15/273a4baf8248d6d76220723c3caf039d283774b31a7c46ba686120145b76/eval_type_backport-0.4.0.tar.gz", hash = "sha256:8397d25e6524c2e67b9576bb0636be27dea2192017711220c534ec2de921e9b0", size = 10260, upload-time = "2026-06-02T13:22:06.059Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/50/a7/bb99bf5e6f78736ddb53480f2c3ff3702ffe2196a7c5e1661c03081d398e/eval_type_backport-0.4.0-py3-none-any.whl", hash = "sha256:ad5e2a8db71b6696a56eafb938b0f5a337d3217f256b8e158b469422b4772b20", size = 6432, upload-time = "2026-06-02T13:22:04.827Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "fastapi" version = "0.141.1" @@ -780,98 +527,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] -[[package]] -name = "fastavro" -version = "1.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/5b/ccb338db71f347e3bc031d268bf6dc41e5ead63b6997b8e72af92f05e18e/fastavro-1.12.2.tar.gz", hash = "sha256:3c79502d56cf6b76210032e1c53494ddfbc73c140bccf2ef4092b3f0825323ab", size = 1030127, upload-time = "2026-04-24T14:36:01.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/39/f489a441d41cc9c0a8449fb1325d7a9c9eb57a5634e6ab19dfb0a1105324/fastavro-1.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:57bb6b908cb2e05baab63b04c3a31be3b4545a10bfab9748b8763016b5256704", size = 958566, upload-time = "2026-04-24T14:36:45.49Z" }, - { url = "https://files.pythonhosted.org/packages/31/69/776cc025aee2d02acacb734cf690d2fbc295eaadde1b5d47caf8c77a6a2b/fastavro-1.12.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a007f95cc682f56e6d83f1d17c29c00bf719d6fe8e003282b535af3a1ba09c0", size = 3276390, upload-time = "2026-04-24T14:36:47.875Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bc/b7e15fa788f42cbe65827af2ec06c9ad91bb9f72c213110dbef61b53a5b0/fastavro-1.12.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e90460b0cd21f62be3cb26087e706e2cebb7b3fcef9e05b4473b61bb0415b5e", size = 3372779, upload-time = "2026-04-24T14:36:50.122Z" }, - { url = "https://files.pythonhosted.org/packages/79/c2/98993ca810231fc1397212f48c3d46626983722a24bbaaa5c27ee0963751/fastavro-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ccd15966b8218d41b06ec3e7c2556be89a8a693026c771e6564d2e40bbaf8ea", size = 3187591, upload-time = "2026-04-24T14:36:52.451Z" }, - { url = "https://files.pythonhosted.org/packages/c6/bb/c180f340eba6478f1b20deccdd17e2b4a4d5074dafd812e3c4254fd035f7/fastavro-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06b6971d3dae10cb34353b857d16ad21ebd6f0ea394e86c96abdcad109005d6e", size = 3320589, upload-time = "2026-04-24T14:36:54.647Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e9/aca0456216b5b8992e7b0a8542711b66799c05bfe24c8e32ef6f56e7eb93/fastavro-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:98dfcdfaf1498ae2f0e2fafe900a82e8320cc81d8ae5a95b8b8879eaa3298c39", size = 440883, upload-time = "2026-04-24T14:36:56.585Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7e/984896e716af504927be71b80a1e9661aa96c6f9e1e777d52823aacb99f2/fastavro-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:3888ef7a51adc77cdf07251bc762566a1be36211e1cff689f13980f3776a2f36", size = 377536, upload-time = "2026-04-24T14:36:58.274Z" }, - { url = "https://files.pythonhosted.org/packages/e9/42/09a1e1f8d9998d73848a6ff0aad6713ae6abf0dbf99918776f8ef33344a7/fastavro-1.12.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:283dcd3129b632021894425974bedd0eb6db3bbf5994e448ccad10db4d803d31", size = 1049506, upload-time = "2026-04-24T14:36:59.797Z" }, - { url = "https://files.pythonhosted.org/packages/52/ef/80cc16f43919d532f25a707f34b275cccc09dca87a05b000fbbfc8e8f255/fastavro-1.12.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d125e210d5a0a1f701f12c0ecad9a03f1b04b5eddbce6ca36a1fc217da977ef", size = 3495899, upload-time = "2026-04-24T14:37:02.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/54/a0817d1d0236e9e0233f5c996f450cc795b056b8e06edb531f24b9df82ed/fastavro-1.12.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d4d66afad78e8f47feaa307728a6b71fe3effc63ba2b9eeb109ee687c9bd397", size = 3399232, upload-time = "2026-04-24T14:37:04.837Z" }, - { url = "https://files.pythonhosted.org/packages/38/0a/650f256c15f5875b6081544b9ba7ed8254329213e7e49e3db0aec68b5bee/fastavro-1.12.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2328ec07925c04c89719e3971c9068a165c7fd474ea87675b1204de0440e71ff", size = 3320222, upload-time = "2026-04-24T14:37:07.281Z" }, - { url = "https://files.pythonhosted.org/packages/f5/54/8351d388f94fbb0870e8cffaae41d3cc607acc8d6a8a6a217e2794829593/fastavro-1.12.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:55dea7e74b834d4b70467fc19c5b9ccb5509fe39abc4d26891187c1b22176423", size = 3337096, upload-time = "2026-04-24T14:37:09.452Z" }, -] - -[[package]] -name = "fastmcp" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastmcp-slim", extra = ["client", "server"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/e2c78e26233cd8a416b21513e1925435d54c008a0ec467dbdaa80369daf7/fastmcp-3.4.6.tar.gz", hash = "sha256:2287938da8364ad7071bec2d2393af6ae10fd4e836f06f506569f1456cc87eb4", size = 28808130, upload-time = "2026-08-05T14:54:42.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/a5/c02275db111892388972edbb05fbcbfdf1e83cbd1fd03356b3a49b93f839/fastmcp-3.4.6-py3-none-any.whl", hash = "sha256:2a29967be9f68cdd1b4cefb413ede74f83adefd923919a37aaac3611eccdd749", size = 8017, upload-time = "2026-08-05T14:54:38.473Z" }, -] - -[[package]] -name = "fastmcp-slim" -version = "3.4.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "pydantic", extra = ["email"] }, - { name = "pydantic-settings" }, - { name = "python-dotenv" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ca/b6/b5b9e81e67a3f39534881d2af6aa3fbd2dc367eaa070c3c932770a0c062f/fastmcp_slim-3.4.6.tar.gz", hash = "sha256:6a1e6e42c697ba90abcb1be617a26947d07316f13c6fa138ae7b0de24558e32c", size = 594167, upload-time = "2026-08-05T14:54:15.924Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/0b/02c254c46b4323ae5262b76a29af73b50a863efc5739a3454d144d3605ee/fastmcp_slim-3.4.6-py3-none-any.whl", hash = "sha256:3e08e6acb03523a47aa17f4d2ab9943e648a41e20b24084da491a732c46dffdc", size = 769174, upload-time = "2026-08-05T14:54:14.663Z" }, -] - -[package.optional-dependencies] -client = [ - { name = "authlib" }, - { name = "exceptiongroup" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "opentelemetry-api" }, - { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "starlette" }, -] -server = [ - { name = "authlib" }, - { name = "cyclopts" }, - { name = "exceptiongroup" }, - { name = "griffelib" }, - { name = "httpx" }, - { name = "joserfc" }, - { name = "jsonref" }, - { name = "jsonschema-path" }, - { name = "mcp" }, - { name = "openapi-pydantic" }, - { name = "opentelemetry-api" }, - { name = "packaging" }, - { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, - { name = "pyperclip" }, - { name = "python-multipart" }, - { name = "pyyaml" }, - { name = "starlette" }, - { name = "uncalled-for" }, - { name = "uvicorn" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "filelock" -version = "3.32.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, -] - [[package]] name = "fonttools" version = "4.63.0" @@ -910,56 +565,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/af012eda9507494f28b99b077423806c43a11573eb6225dd46f19ae2d263/fpdf2-2.8.8-py3-none-any.whl", hash = "sha256:3557a478fc577a929c94aace9666aed4dcc432b5ab6764232e6a59f1ccd75f17", size = 337000, upload-time = "2026-08-09T23:32:43.728Z" }, ] -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "fsspec" -version = "2026.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, -] - [[package]] name = "genai-prices" version = "0.1.1" @@ -982,57 +587,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, ] -[[package]] -name = "google-auth" -version = "2.56.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/4c/fa42116a48bab3f7a143cf5042ecff7df9c8b73f8a376203cd534d1dc966/google_auth-2.56.3.tar.gz", hash = "sha256:40e229fc901f0a305b553050e5fce562d509bee0435be053abfa91582b51b90c", size = 367110, upload-time = "2026-08-06T06:24:01.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b3/6117b2f24065cd7e2c4f140e9a193e215f089ca8ba314cf91eb9d0b7fe0a/google_auth-2.56.3-py3-none-any.whl", hash = "sha256:8ec438808f813ad034535000261eed1067475d229d05bbf4216e78c3f2362e53", size = 259116, upload-time = "2026-08-06T06:22:51.788Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-genai" -version = "2.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/2e/03a0486e192cde27f7ffbb20c63fa934a132396a667b1a0e1b7e5f606ad7/google_genai-2.17.0.tar.gz", hash = "sha256:6b640a2390c82b4a240873eddb9f518c6d2c33244b2de16ed3d14526a6da7f57", size = 648676, upload-time = "2026-08-06T05:10:41.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/37/71e397a5b93d9a3c139e95acdcb00b4169ed59ae20e16616f2875170abb7/google_genai-2.17.0-py3-none-any.whl", hash = "sha256:a4835563c60aee646c9c4b261c507aa4a624710d25017012d20dc65abf3d9a54", size = 1044950, upload-time = "2026-08-06T05:10:39.308Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.75.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/73/74bcab964c9a7a61f2bb71e8179b0f13e6fa98f7ce00fd168aab291e4a2e/googleapis_common_protos-1.75.1.tar.gz", hash = "sha256:d3042c6c5a2d4e67113104d6b6818b59b6bd92a197f2a91508e801fe815cf071", size = 150967, upload-time = "2026-08-06T06:24:51.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/51/186c02b8549b69ccda44429cf6ff5081e4b61a602ddfe6a8020d1be31d1b/googleapis_common_protos-1.75.1-py3-none-any.whl", hash = "sha256:28a1934bcd33b9c9da66ac301a0a4227e3367f095a17d0375cb98f0a09d93b79", size = 300626, upload-time = "2026-08-06T06:23:46.696Z" }, -] - [[package]] name = "griffelib" version = "2.1.0" @@ -1042,44 +596,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] -[[package]] -name = "groq" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/67/3e/687064918e861082cb02165872bbda31269126a108a78794dae86ddadae2/groq-1.6.0.tar.gz", hash = "sha256:c4237ecf0053ba85fd926bb31cb4b178996310474b4618867994e3e40d8e3c02", size = 158424, upload-time = "2026-07-24T17:25:49.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/40/164338d796cf41ea5930a42023e986d8c993720bed670d5aa143e5854873/groq-1.6.0-py3-none-any.whl", hash = "sha256:fa16db582455db324adcff1b5908519474736872e28cd5c8fa2b8bef6860e12a", size = 143693, upload-time = "2026-07-24T17:25:48.2Z" }, -] - -[[package]] -name = "grpcio" -version = "1.83.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, - { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, - { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, - { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, - { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, - { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1089,30 +605,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "hf-xet" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload-time = "2026-03-31T22:39:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload-time = "2026-03-31T22:39:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload-time = "2026-03-31T22:39:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload-time = "2026-03-31T22:39:37.936Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload-time = "2026-03-31T22:39:58.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload-time = "2026-03-31T22:40:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload-time = "2026-03-31T22:40:10.42Z" }, - { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload-time = "2026-03-31T22:40:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" }, -] - [[package]] name = "httpcore" version = "1.0.9" @@ -1154,15 +646,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - [[package]] name = "httpx2" version = "2.10.0" @@ -1188,27 +671,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] -[[package]] -name = "huggingface-hub" -version = "1.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d8/748ea0a47f0fa15227fe682f7a80826b4b7c096e4818044b8f56d6cb66d6/huggingface_hub-1.18.0.tar.gz", hash = "sha256:f0c5ecd1ef8c6a60f86f61ee278f2c1570ba9e279c9f54de9094210723b3613b", size = 812699, upload-time = "2026-06-05T09:26:33.401Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/03/40a05316cb6616e5b7efd7773656441ab04b4b022c2199e79bb4622a92a3/huggingface_hub-1.18.0-py3-none-any.whl", hash = "sha256:729be4a976fb706dcc02d176bcda8a3f32bdf21a294e8f4b3dda6fbcbc9c1ab1", size = 684411, upload-time = "2026-06-05T09:26:31.48Z" }, -] - [[package]] name = "idna" version = "3.18" @@ -1261,48 +723,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] -[[package]] -name = "jaraco-classes" -version = "3.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, -] - -[[package]] -name = "jaraco-context" -version = "6.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, -] - -[[package]] -name = "jaraco-functools" -version = "4.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, -] - -[[package]] -name = "jeepney" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -1337,217 +757,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, ] -[[package]] -name = "jmespath" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, -] - -[[package]] -name = "joserfc" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, -] - -[[package]] -name = "jsonpatch" -version = "1.33" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonpointer" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, -] - -[[package]] -name = "jsonpath-python" -version = "1.1.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178, upload-time = "2026-05-07T01:26:34.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" }, -] - -[[package]] -name = "jsonpointer" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, -] - -[[package]] -name = "jsonref" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-path" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "pathable" }, - { name = "pyyaml" }, - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "keyring" -version = "25.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jaraco-classes" }, - { name = "jaraco-context" }, - { name = "jaraco-functools" }, - { name = "jeepney", marker = "sys_platform == 'linux'" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "secretstorage", marker = "sys_platform == 'linux'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, -] - -[[package]] -name = "langchain-core" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jsonpatch" }, - { name = "langchain-protocol" }, - { name = "langsmith" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "uuid-utils" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/65/3e/63af6b9d76d9be907c7c524d6ec18a2efed7e0e2d123fea0230d78dbd73f/langchain_core-1.5.3.tar.gz", hash = "sha256:a56457ac444fef41e9404443c187f0ecea708d36e816ea4ba9573c027f7d1a2d", size = 972461, upload-time = "2026-07-30T14:55:55.833Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/e6/c7c39efe0bc7e1b7c3d8f54f85846e04c901913c3d3e99068b218558c6f1/langchain_core-1.5.3-py3-none-any.whl", hash = "sha256:48b56fa580277209594dd7baf837f5b9a2a3651613f34ff9fb1728b429df015f", size = 561687, upload-time = "2026-07-30T14:55:54.419Z" }, -] - -[[package]] -name = "langchain-protocol" -version = "0.0.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, -] - -[[package]] -name = "langchain-text-splitters" -version = "1.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/9f/6c545900fefb7b00ddfa3f16b80d61338a0ec68c31c5451eeeab99082760/langchain_text_splitters-1.1.2.tar.gz", hash = "sha256:782a723db0a4746ac91e251c7c1d57fd23636e4f38ed733074e28d7a86f41627", size = 293580, upload-time = "2026-04-16T14:20:39.162Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" }, -] - -[[package]] -name = "langsmith" -version = "0.10.17" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, - { name = "packaging" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "requests-toolbelt" }, - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "uuid-utils" }, - { name = "websockets" }, - { name = "xxhash" }, - { name = "zstandard" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/56/73674bd922fe57509c38075d089017d65e02f50d687fd70ee6c0b153d729/langsmith-0.10.17.tar.gz", hash = "sha256:2bf0e69246ef77282ea93e78eb22dad1ec8d8a002d31cbd607d0d6448f9c6458", size = 4798702, upload-time = "2026-08-07T17:23:04.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/e6/e264e87ce79467aa0cc6c5e945e74050d7880394da325b3559cfdb41a186/langsmith-0.10.17-py3-none-any.whl", hash = "sha256:2a1242a0500147ed846ecbd5fe2f41c21b1652f6833b1af0f209231006f67e5e", size = 734484, upload-time = "2026-08-07T17:23:02.598Z" }, -] - -[[package]] -name = "logfire" -version = "4.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "executing" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-sdk" }, - { name = "protobuf" }, - { name = "rich" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/71/1ed06d124bd7905f60004d60cbe14c79bda00f0604bf0cb76214f8c96348/logfire-4.40.0.tar.gz", hash = "sha256:f50f9637f9b5cc3eb5f8526e473effa1992d45056c89adc7c821ee7ab2520c75", size = 1260846, upload-time = "2026-08-05T11:26:59.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/65/2ed148a656362ff3b5cce0bfae619cf57ca1df33d0c942b4c1461e57f57c/logfire-4.40.0-py3-none-any.whl", hash = "sha256:0ac2c950968812f27ee68ccec4a362230acffaffb28f04945ee07729d5866fa0", size = 409440, upload-time = "2026-08-05T11:26:56.745Z" }, -] - -[package.optional-dependencies] -httpx = [ - { name = "opentelemetry-instrumentation-httpx" }, -] - [[package]] name = "logfire-api" version = "4.40.0" @@ -1557,18 +766,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/be/ebe35d94e7d567b58d79bd7e1085fe85195ad4b8c8df8882a5a46caa4984/logfire_api-4.40.0-py3-none-any.whl", hash = "sha256:f8b7309235a942368b927f00e0a1869ff0820833f264a30e77a35f1da829c130", size = 140593, upload-time = "2026-08-05T11:26:58.395Z" }, ] -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -1599,59 +796,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] -[[package]] -name = "mcp" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mistralai" -version = "2.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "eval-type-backport" }, - { name = "httpx" }, - { name = "jsonpath-python" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d5/e0/9d3e8bcfa73357f997ea297148d16ac243d1a228838334179a9199b713ff/mistralai-2.9.1.tar.gz", hash = "sha256:5b3983c6fddc81b898f1dd5a61a96a59a0e18069c54940cac9b5220dd6b66486", size = 534224, upload-time = "2026-08-04T16:23:45.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/6e/11d537beb67fd7dc0f1028c4a9f4f8230c6328abf6e2f2c9d0447ea0e4a4/mistralai-2.9.1-py3-none-any.whl", hash = "sha256:6f83177b8c4fdffdd2a054cf9d96fc7c0d7a474e856ecd1f0b7070344128516b", size = 1270991, upload-time = "2026-08-04T16:23:43.001Z" }, -] - [[package]] name = "more-itertools" version = "11.1.0" @@ -1661,51 +805,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1715,18 +814,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] -[[package]] -name = "nexus-rpc" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/d5/cd1ffb202b76ebc1b33c1332a3416e55a39929006982adc2b1eb069aaa9b/nexus_rpc-1.4.0.tar.gz", hash = "sha256:3b8b373d4865671789cc43623e3dc0bcbf192562e40e13727e17f1c149050fba", size = 82367, upload-time = "2026-02-25T22:01:34.053Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/52/6327a5f4fda01207205038a106a99848a41c83e933cd23ea2cab3d2ebc6c/nexus_rpc-1.4.0-py3-none-any.whl", hash = "sha256:14c953d3519113f8ccec533a9efdb6b10c28afef75d11cdd6d422640c40b3a49", size = 29645, upload-time = "2026-02-25T22:01:33.122Z" }, -] - [[package]] name = "nodeenv" version = "1.10.0" @@ -1774,18 +861,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, ] -[[package]] -name = "openapi-pydantic" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, -] - [[package]] name = "opencv-python-headless" version = "5.0.0.93" @@ -1818,79 +893,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "requests" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "packaging" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, -] - -[[package]] -name = "opentelemetry-instrumentation-httpx" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-instrumentation" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "opentelemetry-util-http" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, -] - [[package]] name = "opentelemetry-sdk" version = "1.39.1" @@ -1918,38 +920,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] -[[package]] -name = "opentelemetry-util-http" -version = "0.60b1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, -] - -[[package]] -name = "orjson" -version = "3.11.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, - { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, - { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, - { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, - { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, - { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, - { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, - { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -1981,15 +951,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, ] -[[package]] -name = "pathable" -version = "0.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, -] - [[package]] name = "pathspec" version = "1.1.1" @@ -2073,76 +1034,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/f4/e5a968a7f843dc31215c694e2edfafa0644e9b9e833588c1f4adfc0175ee/posthog-7.38.3-py3-none-any.whl", hash = "sha256:099aeea324b6c20e352a5bf37a43428fce20c76459d3d27da3b8c8df7814179f", size = 496878, upload-time = "2026-08-07T18:05:12.178Z" }, ] -[[package]] -name = "prompt-toolkit" -version = "3.0.53" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, -] - -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, -] - -[[package]] -name = "protobuf" -version = "6.33.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, -] - [[package]] name = "psycopg" version = "3.3.4" @@ -2193,52 +1084,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" }, ] -[[package]] -name = "py-key-value-aio" -version = "0.4.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "beartype" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, -] - -[package.optional-dependencies] -filetree = [ - { name = "aiofile" }, - { name = "anyio" }, -] -keyring = [ - { name = "keyring" }, -] -memory = [ - { name = "cachetools" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - [[package]] name = "pycparser" version = "3.0" @@ -2263,23 +1108,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] -[package.optional-dependencies] -email = [ - { name = "email-validator" }, -] - -[[package]] -name = "pydantic-ai" -version = "1.107.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic-ai-slim", extra = ["ag-ui", "anthropic", "bedrock", "cli", "cohere", "evals", "fastmcp", "google", "groq", "huggingface", "logfire", "mcp", "mistral", "openai", "retries", "spec", "temporal", "ui", "vertexai", "xai"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/ac/2edbefc3b6787d4e96a5c2d9764ceb95113dd6c5ed1db96fb0a6b75620ba/pydantic_ai-1.107.2.tar.gz", hash = "sha256:324f8a67821100b51c7d26667bea73bd6c229c9298093cdaa133478f389964a0", size = 18414, upload-time = "2026-08-08T03:31:43.502Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/5d/006df19f193276592def31b87be34d821376753f7a28cd993fde2c3b109f/pydantic_ai-1.107.2-py3-none-any.whl", hash = "sha256:d0705b09b7941f36d9859c6c4b2c32d2b477ba7c2326f33709f8869799cb5c21", size = 7588, upload-time = "2026-08-08T03:31:35.204Z" }, -] - [[package]] name = "pydantic-ai-slim" version = "1.107.2" @@ -2299,78 +1127,13 @@ wheels = [ ] [package.optional-dependencies] -ag-ui = [ - { name = "ag-ui-protocol" }, - { name = "starlette" }, -] anthropic = [ { name = "anthropic" }, ] -bedrock = [ - { name = "boto3" }, -] -cli = [ - { name = "argcomplete" }, - { name = "prompt-toolkit" }, - { name = "pyperclip" }, - { name = "pyyaml" }, - { name = "rich" }, -] -cohere = [ - { name = "cohere", marker = "sys_platform != 'emscripten'" }, -] -evals = [ - { name = "pydantic-evals" }, -] -fastmcp = [ - { name = "fastmcp" }, -] -google = [ - { name = "google-genai" }, -] -groq = [ - { name = "groq" }, -] -huggingface = [ - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "huggingface-hub" }, -] -logfire = [ - { name = "logfire", extra = ["httpx"] }, -] -mcp = [ - { name = "fastmcp-slim", extra = ["client"] }, -] -mistral = [ - { name = "mistralai" }, -] openai = [ { name = "openai" }, { name = "tiktoken" }, ] -retries = [ - { name = "tenacity" }, -] -spec = [ - { name = "pydantic-handlebars" }, - { name = "pyyaml" }, -] -temporal = [ - { name = "temporalio" }, -] -ui = [ - { name = "starlette" }, -] -vertexai = [ - { name = "google-auth" }, - { name = "requests" }, -] -voyageai = [ - { name = "voyageai" }, -] -xai = [ - { name = "xai-sdk" }, -] [[package]] name = "pydantic-core" @@ -2398,23 +1161,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, ] -[[package]] -name = "pydantic-evals" -version = "1.107.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "logfire-api" }, - { name = "pydantic" }, - { name = "pydantic-ai-slim" }, - { name = "pyyaml" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/75/09/ef127d1f56a33759c7f8ed8aa687d338fdc00be0bad07055044db8c2010e/pydantic_evals-1.107.2.tar.gz", hash = "sha256:ead8c28c3d85a5d43df34eab6da9f8ac848e97e20964494713f4605430931de0", size = 78543, upload-time = "2026-08-08T03:31:46.681Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/f5/75a23d488201310504040443a9107ca8537d0f2c8c0422e49c143311351e/pydantic_evals-1.107.2-py3-none-any.whl", hash = "sha256:e72d062035cc9c1629476483443fc7e53bfc635c48f1d37bac6af3a86090ee17", size = 93584, upload-time = "2026-08-08T03:31:39.737Z" }, -] - [[package]] name = "pydantic-graph" version = "1.107.2" @@ -2430,18 +1176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/84/f790f84728cafba744d8d55cb1d1dd9a659d0cd74d55b49bc514763a006c/pydantic_graph-1.107.2-py3-none-any.whl", hash = "sha256:ec8866d61ec042988f05946140a226d6d8dd5b24a0cb4647a638fc87c493edfd", size = 80106, upload-time = "2026-08-08T03:31:41.271Z" }, ] -[[package]] -name = "pydantic-handlebars" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/73/e55a1fe1a8788a5fa82d9209e796f4111e28f2d2fecab7173aa6d80516ad/pydantic_handlebars-0.2.1.tar.gz", hash = "sha256:d4124cfbf7d6e3bded9331a08ccccf6f29f3e3a93665b35b5d6061650aeeb49f", size = 176949, upload-time = "2026-05-25T01:24:38.354Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/11/364bc401f1d8fdb3947079fc43ffdfbfc9132d065981a03a95d2e87440c4/pydantic_handlebars-0.2.1-py3-none-any.whl", hash = "sha256:c713427d6498cf4b66814447d54753a2748f8a8d3a9f00c194192ddb3df61e52", size = 50476, upload-time = "2026-05-25T01:24:37.104Z" }, -] - [[package]] name = "pydantic-settings" version = "2.15.0" @@ -2474,20 +1208,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[package]] -name = "pyjwt" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pypdf" version = "6.15.0" @@ -2502,15 +1222,6 @@ crypto = [ { name = "cryptography" }, ] -[[package]] -name = "pyperclip" -version = "1.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, -] - [[package]] name = "pyphen" version = "0.17.2" @@ -2549,18 +1260,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -2570,15 +1269,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-multipart" -version = "0.0.32" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, -] - [[package]] name = "pytokens" version = "0.4.1" @@ -2593,25 +1283,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] -[[package]] -name = "pywin32" -version = "312" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, -] - -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2728,44 +1399,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - -[[package]] -name = "rich-rst" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, -] - [[package]] name = "rpds-py" version = "2026.6.3" @@ -2814,40 +1447,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, ] -[[package]] -name = "s3transfer" -version = "0.19.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, -] - -[[package]] -name = "secretstorage" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -2887,19 +1486,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/89/81b2907cda14e566b9bf215e2ad82fc9b349edf07d2010756ffdb902f328/sqlite_vec-0.1.9-py3-none-win_amd64.whl", hash = "sha256:4a28dc12fa4b53d7b1dced22da2488fade444e96b5d16fd2d698cd670675cf32", size = 292804, upload-time = "2026-03-31T08:02:36.035Z" }, ] -[[package]] -name = "sse-starlette" -version = "3.4.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "starlette" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, -] - [[package]] name = "starlette" version = "1.6.0" @@ -2912,36 +1498,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] -[[package]] -name = "temporalio" -version = "1.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nexus-rpc" }, - { name = "protobuf" }, - { name = "types-protobuf" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/43/676b56efaa64def06d4a9282cfc08d5a5b1c89de0ded47b692f8eaa30ca8/temporalio-1.31.0.tar.gz", hash = "sha256:2993f4b880170825414116dec7d7159a5d199efc279f29570be6b5c4a9f6edd5", size = 2785306, upload-time = "2026-07-29T17:55:10.995Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/7c/b91d831df50651a26562285de7c434a14243504eed853513da6e6afa3f19/temporalio-1.31.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:5f9aaf06000768eb80cf5774457d227379dae455a3099aa0a18c4dca631c0d27", size = 14505266, upload-time = "2026-07-29T17:54:52.932Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d9/4655fc5d7ea662aac2af972e114ffeae45800bcded5ca3d31b2bcd99179d/temporalio-1.31.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:abbfb486ba00053ccfa57c9c38b38f80f5dbfd08c67beb7a621515a57a9b9e9b", size = 14037187, upload-time = "2026-07-29T17:54:55.771Z" }, - { url = "https://files.pythonhosted.org/packages/52/8e/52bd6a90cba3d4b257d7aa7426598e622cf05ab39a174b477535eba59c14/temporalio-1.31.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e840b462125274cb0d4748e7cedc386c303b2a51bf76bab30b96f1ebbc657ae", size = 14445065, upload-time = "2026-07-29T17:54:58.254Z" }, - { url = "https://files.pythonhosted.org/packages/d9/91/b644c2122943939e02c0e0ff1902b9c6ff7d5e0d6eec334abbfd4ee6bb17/temporalio-1.31.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04d26f0f634f5325f6a19a45cd67cc5a2da4bd35268d27997f6723134d38c8be", size = 14831115, upload-time = "2026-07-29T17:55:00.923Z" }, - { url = "https://files.pythonhosted.org/packages/8f/42/abc82a89323234026753ac801a1dbc36b4322dcdec5f1e38bee6405f3493/temporalio-1.31.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f23f36d0e5d2e67f2129fc1cb876cf1e4e614f791a717d692f7c15fb732abe41", size = 14542828, upload-time = "2026-07-29T17:55:03.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/67/9260f4544eb5d44867ef58e4a0e63dd3d643e26b2a94e21e027ceaeb0059/temporalio-1.31.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:52f8cc7f0b5a19d49f0c2d748420267aaacc2be0bb614743de8d10faf77a57c9", size = 15019517, upload-time = "2026-07-29T17:55:05.758Z" }, - { url = "https://files.pythonhosted.org/packages/ce/96/b71e66a1921906f072038286e30946098531fd3363029f3d08c1723aa39a/temporalio-1.31.0-cp310-abi3-win_amd64.whl", hash = "sha256:aa6e9602829584b22037da6ccf99d2e089f02cec86ee7178206b1afb115cf092", size = 15527552, upload-time = "2026-07-29T17:55:08.287Z" }, -] - -[[package]] -name = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - [[package]] name = "tiktoken" version = "0.13.0" @@ -2992,33 +1548,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, ] -[[package]] -name = "tokenizers" -version = "0.23.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "huggingface-hub" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, - { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, - { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, - { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, - { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, - { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, - { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, -] - [[package]] name = "tomli-w" version = "1.2.0" @@ -3070,42 +1599,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, ] -[[package]] -name = "typer" -version = "0.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, -] - -[[package]] -name = "types-protobuf" -version = "7.34.1.20260518" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/29/59/e2b13b499d15e6720150c4b1a8d91e31fcacf716b432397475b3151ff7e4/types_protobuf-7.34.1.20260518.tar.gz", hash = "sha256:28cfaded25889cb83ebfb63cfb0a43628f0b6f3785767bec17287dc6468795f2", size = 68936, upload-time = "2026-05-18T06:01:47.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/1f/ec5caf72c2e3b688ca3927e0979a04ddad19e1afc4bf1c199bd743e0f419/types_protobuf-7.34.1.20260518-py3-none-any.whl", hash = "sha256:a0a5337413347166439c0e07cbc26c6164d091401c6f01b1dfd8cdb966c4dd8f", size = 85992, upload-time = "2026-05-18T06:01:45.696Z" }, -] - -[[package]] -name = "types-requests" -version = "2.33.0.20260712" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/51/703318f7b7be8bee126ec13bf615050f932d0179b8784420f3a0199cc769/types_requests-2.33.0.20260712.tar.gz", hash = "sha256:2141b67ab534a5c5cd2dac5034f2a35f42e699c5bf185eee608c5246a069d7fb", size = 25084, upload-time = "2026-07-12T05:14:20.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/e7/010c87f559e216d83f9dc51e939633fd0d0ead3377340181ab0e223cd3b5/types_requests-2.33.0.20260712-py3-none-any.whl", hash = "sha256:de027e28c171d3da529689cbfa023b0b4eab188c8dfa22fd834eebd2cee6e7bb", size = 21392, upload-time = "2026-07-12T05:14:19.616Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" @@ -3136,15 +1629,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] -[[package]] -name = "uncalled-for" -version = "0.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, -] - [[package]] name = "unoserver" version = "3.7" @@ -3163,29 +1647,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] -[[package]] -name = "uuid-utils" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/dd/614fb9912157ac0128e6050859ccf06d9f13df9a944a803e8f80f6157e38/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d11a7bc1e02da8984d32e6de9e0826c6edac00eac17de270f372bf32f9a0af63", size = 557259, upload-time = "2026-07-09T13:48:45.664Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/d072711704de3d21bec08b6c2f36a215200ca1d5e01a390ea1ac434080a0/uuid_utils-0.17.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7a49f47ac26df3e431c56b825c1bae8e6d3d591fdbb7438c227cc9845a7e3d73", size = 286271, upload-time = "2026-07-09T13:48:47.018Z" }, - { url = "https://files.pythonhosted.org/packages/18/6d/8a63e5eb2d5a6ba69a6c2036e305075bd6f5a022e7ea25fc6ce0eb7c51d2/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32df1944808877702ceea398c103881c09a679bb672a215e01c2a84231266bf9", size = 320025, upload-time = "2026-07-09T13:48:48.208Z" }, - { url = "https://files.pythonhosted.org/packages/f7/2d/bdc2caf9719d9090d7c46043242ae6136cba4f7a7ee384992ab905ad9aa1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98c88d3edd08e7245562e9815996dbc6f0bd4745e1c76462f24af5ae4e187dd1", size = 327931, upload-time = "2026-07-09T13:48:49.673Z" }, - { url = "https://files.pythonhosted.org/packages/b6/33/9219d09d51ead282b578b2a4e0a515c2cce3ec52076cada8bfb7e35727d5/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4370089c8b2e42f1db51d76408c7fa8eaa2934bf854d17983d16179c07c098", size = 438537, upload-time = "2026-07-09T13:48:50.842Z" }, - { url = "https://files.pythonhosted.org/packages/d8/79/e8e0f8b3955f2081c116157119d87659937893242eb834aa170da04d660b/uuid_utils-0.17.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09a55b7a5ae764985cb46467496a1787678d0a1400356157a080ad95b1a36869", size = 320656, upload-time = "2026-07-09T13:48:52.164Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/d1ceddc430ff04b6e21704b2030d4438074a2f478b265dab43da957791c1/uuid_utils-0.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56aa6488b931246fae11924e4bd0e2b32677e63945eecb71c29e3c2ca0dc3131", size = 345310, upload-time = "2026-07-09T13:48:54.076Z" }, - { url = "https://files.pythonhosted.org/packages/d5/62/89438e12f389a843e626b7e37691319a057b3d6b80914609106891faadda/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:309a35f12d99dde19032bc2259cda6431c85eeac0879134dc777cc3087d7e1cb", size = 496771, upload-time = "2026-07-09T13:48:55.365Z" }, - { url = "https://files.pythonhosted.org/packages/87/d2/eedcd99f522d60e238ead03844f0d51743ba84d33044959e230b756bf212/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:21c79b61ff750abcf057163dd764ccb6196cde7a26cda1b31b45cd97769e03b3", size = 603631, upload-time = "2026-07-09T13:48:56.746Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a8/bb1b38aaddd7243b6e562c6694f499bf094800918316192fd8cb2cdc2620/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4134353bfe3026ddab8e886002dc52bc5a0ab04611aabb0eaae23c32e6e57f64", size = 562008, upload-time = "2026-07-09T13:48:58.241Z" }, - { url = "https://files.pythonhosted.org/packages/b4/77/5f7ed930dc105e293845c09e4d5bd84076318a12f45a46783e1af64906d7/uuid_utils-0.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c89359affecebe2e39e6a116d069b363c936511a9572b308402489a26957d89", size = 525527, upload-time = "2026-07-09T13:48:59.784Z" }, - { url = "https://files.pythonhosted.org/packages/fd/25/1b55697adf6811a6f92cff6340e6b03e31fd6bc51066a5c10698c29b3679/uuid_utils-0.17.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:6a019a31bc4db89a0903a3e4f6b218571f3a6ff0ad4b3d3fe1c8f91a05ff6e3e", size = 97965, upload-time = "2026-07-09T13:49:01.217Z" }, - { url = "https://files.pythonhosted.org/packages/26/bf/cd729343de4684230be8a966bad7bfc2cf10ce3e643b1189a8b5370dbe35/uuid_utils-0.17.0-cp313-cp313-win32.whl", hash = "sha256:b3131a82d0c7611f0aa480a6d36929e001a3f54ba0fc029a8118a5863cce513c", size = 167316, upload-time = "2026-07-09T13:49:02.354Z" }, - { url = "https://files.pythonhosted.org/packages/76/f0/e602ae0a1b139a7826e5189b93d91902564def06d5006324fd2faf82c8fc/uuid_utils-0.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e311f908d2f842fca4c7dcebc4f10306b8089b204ef04cf6704b4332c9ff6ff", size = 173630, upload-time = "2026-07-09T13:49:03.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/024ebece265b387154115dc4f1d9727174ef82623069f4bec8b7ed7e73f7/uuid_utils-0.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:c351737e2e65497c7200ab4ffb8af97e9f48be6488309abdd265fe08d66ee92f", size = 173214, upload-time = "2026-07-09T13:49:04.836Z" }, -] - [[package]] name = "uvicorn" version = "0.52.3" @@ -3199,71 +1660,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/2b/ebd108734a8204c6b4b93c681c9a38c5273b3ccd5d129fee4ffc1d97772c/uvicorn-0.52.3-py3-none-any.whl", hash = "sha256:116af2710dbf47c80f463cd20ee4884b6662f4c9f227d797ddc7279d2fcc2c7c", size = 79859, upload-time = "2026-08-13T16:50:01.323Z" }, ] -[[package]] -name = "voyageai" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "aiolimiter" }, - { name = "langchain-text-splitters" }, - { name = "numpy" }, - { name = "pillow" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "tenacity" }, - { name = "tokenizers" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/d3/b96ecab961692dd55fc5b54fef916c2bc37a942ef33fcc937779d2aaa161/voyageai-0.5.0.tar.gz", hash = "sha256:ed2775fe9faeb96cc2b3931edc35d76185d19f34f1523d1f70535f0451126ae9", size = 37000, upload-time = "2026-07-10T20:13:14.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/c4/732038d97a0ac02d690f295a9ee28f4d0553894478ca6944411428e544f7/voyageai-0.5.0-py3-none-any.whl", hash = "sha256:5d7e8dc3b74cac4646d4a835a65cf8c9764070c90353e322d84a91a16207e78b", size = 46561, upload-time = "2026-07-10T20:13:13.909Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, -] - -[[package]] -name = "wcwidth" -version = "0.8.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, -] - [[package]] name = "weasyprint" version = "69.0" @@ -3292,152 +1688,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, ] -[[package]] -name = "websockets" -version = "16.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, -] - -[[package]] -name = "wrapt" -version = "1.17.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, -] - -[[package]] -name = "xai-sdk" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-sdk" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/79/722beabb2ffbac413e4e776230ee56db2710e83c02f038f5769f9da748c1/xai_sdk-1.17.0.tar.gz", hash = "sha256:ec695ad8b459a4080c01f6e7bb8abf38ed6a4ec69532bfdd73c4de14db1d485c", size = 452803, upload-time = "2026-06-12T03:44:13.329Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/ad/99ad3209727f6e0e280681c05aa6a49feb130926d939bdb4767ee825b0da/xai_sdk-1.17.0-py3-none-any.whl", hash = "sha256:88a6a53181fa13d55662e3296dafd2a3cf258803450b7d82c7318b8c64e8a485", size = 273997, upload-time = "2026-06-12T03:44:11.828Z" }, -] - -[[package]] -name = "xxhash" -version = "3.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, - { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, - { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, - { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, - { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, - { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, - { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, - { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, - { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, - { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, - { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, - { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, - { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, - { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, - { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, - { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, - { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, - { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, - { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, - { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, - { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, - { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, - { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, - { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, - { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, - { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, - { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, - { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, - { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, - { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, - { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, -] - -[[package]] -name = "yarl" -version = "1.24.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, - { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, - { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, - { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, - { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, - { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, - { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, - { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, - { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, - { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, - { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, -] - [[package]] name = "zipp" version = "4.1.0" @@ -3462,28 +1712,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, ] - -[[package]] -name = "zstandard" -version = "0.25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, - { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, - { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, - { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, - { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, - { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, - { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, - { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, - { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, - { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, - { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, - { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, -] diff --git a/scripts/init-without-ocr.sh b/scripts/init-without-ocr.sh index c55dc1e6d4..88a10ffa28 100755 --- a/scripts/init-without-ocr.sh +++ b/scripts/init-without-ocr.sh @@ -890,6 +890,11 @@ log "Setting permissions..." mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline /storage || true CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/storage" "/tmp/stirling-pdf" "/app.jar") [ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype") +# Chowned here rather than at build time so it follows PUID/PGID remapping. +if [ -d "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" ]; then + mkdir -p "${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data" || true + CHOWN_PATHS+=("${STIRLING_ENGINE_HOME:-/opt/stirling-engine}/data") +fi CHOWN_OK=true for p in "${CHOWN_PATHS[@]}"; do if [ -e "$p" ]; then @@ -962,6 +967,38 @@ else JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher") fi +# ---------- AI engine ---------- +# Only the fat image ships it. The backend already defaults to http://localhost:5001. +STIRLING_ENGINE_HOME="${STIRLING_ENGINE_HOME:-/opt/stirling-engine}" +ENGINE_PID="" +if [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ] && [ "${AIENGINE_ENABLED:-true}" = "false" ]; then + log "AI engine bundled but AIENGINE_ENABLED=false; not starting it." +elif [ -x "$STIRLING_ENGINE_HOME/.venv/bin/python" ]; then + log "Starting bundled AI engine on port ${STIRLING_ENGINE_PORT:-5001}..." + ENGINE_CMD=( + "$STIRLING_ENGINE_HOME/.venv/bin/python" -m uvicorn + stirling.api.app:app + --host 127.0.0.1 + --port "${STIRLING_ENGINE_PORT:-5001}" + --workers "${STIRLING_ENGINE_WORKERS:-2}" + --app-dir "$STIRLING_ENGINE_HOME/src" + ) + # init.sh exports PYTHONPATH for unoserver's 3.12 venv; inheriting it breaks the 3.13 engine. + if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then + env -u PYTHONPATH "${ENGINE_CMD[@]}" & + elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then + env -u PYTHONPATH \ + HOME="$(getent passwd "$RUNTIME_USER" | cut -d: -f6)" \ + USER="$RUNTIME_USER" \ + LOGNAME="$RUNTIME_USER" \ + setpriv --reuid="$RUNTIME_USER" --regid="$(id -gn "$RUNTIME_USER")" --init-groups -- "${ENGINE_CMD[@]}" & + else + env -u PYTHONPATH "${ENGINE_CMD[@]}" & + fi + ENGINE_PID=$! + log "AI engine started (PID $ENGINE_PID)" +fi + if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then "${JAVA_CMD[@]}" & elif [ "$CURRENT_UID" -eq 0 ] && command_exists setpriv; then @@ -1074,6 +1111,12 @@ fi wait "$JAVA_PID" || true exit_code=$? + +if [ -n "$ENGINE_PID" ] && kill -0 "$ENGINE_PID" 2>/dev/null; then + log "Stopping AI engine (PID $ENGINE_PID)..." + kill "$ENGINE_PID" 2>/dev/null || true + wait "$ENGINE_PID" 2>/dev/null || true +fi case "$exit_code" in 0) log "Stirling PDF exited normally." ;; 137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;; From e51885b379dd41ccc566f5b66bd3a1b028503849 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:39:57 +0000 Subject: [PATCH 09/10] build(deps): bump step-security/harden-runner from 2.20.0 to 2.21.0 (#7623) Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.20.0 to 2.21.0.
Release notes

Sourced from step-security/harden-runner's releases.

v2.21.0

What's Changed

  • Support for denied endpoints in block mode. This is included in the enterprise tier. Customers can deny outbound calls, for example, to public package registries.
  • Improved Support for AWS CodeBuild GitHub Actions Runners.
  • Bug fixes.

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.1...v2.21.0

v2.20.1

What's Changed

  • AWS CodeBuild-hosted runner support
  • Implicitly allow single-labeled (internal) domains in block-mode

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1

Commits
  • 05e3151 Merge pull request #684 from step-security/rc-42
  • 0f37afa fix: ignore denied-endpoints on non-enterprise tier
  • 93b58ee fix: resolve cache host read-first and never downgrade egress policy
  • e7399dd fix: align deny-list mode detection with agent and log when both endpoint inp...
  • c16689f test: add denied_endpoints to Configuration fixtures and cover deny-list merge
  • 40b99cf Merge pull request #682 from rohan-stepsecurity/rp/feat/codebuild-self-v2
  • fedec02 Merge branch 'rc-42' into rp/feat/codebuild-self-v2
  • 5361fb1 feat: add build artifacts
  • 286474f feat: Support Bravo agent install on CodeBuild runners
  • 051ec05 Merge pull request #683 from h0x0er/jatin/deny-list
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Auto-Deploy-V2.yml | 6 +++--- .github/workflows/PR-Demo-Comment-with-react.yml | 6 +++--- .github/workflows/PR-Demo-cleanup.yml | 2 +- .github/workflows/_runner-pick.yml | 2 +- .github/workflows/ai-engine.yml | 2 +- .github/workflows/aur-publish.yml | 4 ++-- .github/workflows/auto-labelerV2.yml | 2 +- .github/workflows/backend-build.yml | 2 +- .github/workflows/build-enterprise.yml | 4 ++-- .github/workflows/build.yml | 4 ++-- .github/workflows/check-generated-models.yml | 2 +- .github/workflows/check-licence.yml | 2 +- .github/workflows/check-openapi.yml | 2 +- .github/workflows/check_toml.yml | 2 +- .github/workflows/coverage-aggregate.yml | 2 +- .github/workflows/db-migration-test.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/e2e-live.yml | 2 +- .github/workflows/e2e-stubbed.yml | 2 +- .github/workflows/frontend-a11y.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 6 +++--- .github/workflows/frontend-validation.yml | 2 +- .github/workflows/gradle-cache-prime.yml | 2 +- .github/workflows/manage-label.yml | 2 +- .github/workflows/multiOSReleases.yml | 8 ++++---- .github/workflows/nightly.yml | 6 +++--- .github/workflows/package-managers.yml | 4 ++-- .github/workflows/pr-conflict-labeler.yml | 2 +- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker-base.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/rollback-latest.yml | 2 +- .github/workflows/scorecards.yml | 2 +- .github/workflows/stale.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync-portal-docs.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- .github/workflows/tauri-build.yml | 8 ++++---- .github/workflows/test-build-docker.yml | 6 +++--- .github/workflows/update-gradle.yml | 2 +- 41 files changed, 61 insertions(+), 61 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 2c9f6e2e26..6420712640 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -39,7 +39,7 @@ jobs: pr_ref: ${{ steps.resolve.outputs.ref }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -121,7 +121,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -475,7 +475,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 410aa82dc9..111dba441f 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -70,7 +70,7 @@ jobs: enable_prototypes: ${{ steps.check-prototypes-flag.outputs.enable_prototypes }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -177,7 +177,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -482,7 +482,7 @@ jobs: issues: write # add/remove labels, delete the command comment steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 098f8d7803..f2912dcedb 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/_runner-pick.yml b/.github/workflows/_runner-pick.yml index d65831c5df..ca2337960a 100644 --- a/.github/workflows/_runner-pick.yml +++ b/.github/workflows/_runner-pick.yml @@ -38,7 +38,7 @@ jobs: is_fork: ${{ steps.decide.outputs.is_fork }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index b8e15466e5..caa5af1acb 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index af0dc85aa6..8c658ec69e 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -26,7 +26,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -71,7 +71,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index 8e6d974f3c..a5ce864d2b 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -18,7 +18,7 @@ jobs: issues: write # labels are applied through the issues API steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index e9a27a072d..09c6bbc9a8 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -31,7 +31,7 @@ jobs: flavor: [core, proprietary, saas] steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index 0bd376766e..a5a346db88 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -58,7 +58,7 @@ jobs: SYSTEM_ENABLEANALYTICS: "false" steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -351,7 +351,7 @@ jobs: MN_COMPOSE: docker-compose-multinode.yml steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d35b2ee177..16279c67f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: proprietary: ${{ steps.changes.outputs.proprietary }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository @@ -298,7 +298,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index 4428704b10..476f9d21a7 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 4e04a83656..7626122884 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index bc9b302857..47341834a3 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/check_toml.yml b/.github/workflows/check_toml.yml index 9ccfc4af52..84347defe2 100644 --- a/.github/workflows/check_toml.yml +++ b/.github/workflows/check_toml.yml @@ -28,7 +28,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index 87fbb24eac..899bed7f9d 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 15 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 7176163440..3841ea4410 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -20,7 +20,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b27596ddfb..527dca9703 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index 54f2e6b5f6..cf5887a205 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index 29668940c4..b04f5022cc 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 30 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index b62c3e72ec..5038a7585a 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -32,7 +32,7 @@ jobs: project: stubbed-webkit steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 8f97120d96..247d8375fc 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -21,7 +21,7 @@ jobs: timeout-minutes: 25 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 9aef2316d2..96e0edd8ac 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -28,7 +28,7 @@ jobs: licenses-backend: ${{ steps.changes.outputs.licenses-backend }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -56,7 +56,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -334,7 +334,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 0cad599a1c..2650a945d6 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/gradle-cache-prime.yml b/.github/workflows/gradle-cache-prime.yml index b3700d8ac0..1c79ee4d12 100644 --- a/.github/workflows/gradle-cache-prime.yml +++ b/.github/workflows/gradle-cache-prime.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 15 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit - name: Checkout repository diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 1a8bdd112d..cc27e45946 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 8e1c47b412..9071997ad7 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -48,7 +48,7 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: file_suffix: "-server" steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -207,7 +207,7 @@ jobs: RELEASE_GPG_PRIVATE_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit allowed-endpoints: > @@ -715,7 +715,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 42898c4b43..5daa60f56f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -74,7 +74,7 @@ jobs: timeout-minutes: 60 steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: contents: read steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 777c8d6ed4..02d946e5a6 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -28,7 +28,7 @@ jobs: jar_sha256: ${{ steps.hashes.outputs.jar_sha256 }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -80,7 +80,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/pr-conflict-labeler.yml b/.github/workflows/pr-conflict-labeler.yml index c642b6cf3d..564ea9cd93 100644 --- a/.github/workflows/pr-conflict-labeler.yml +++ b/.github/workflows/pr-conflict-labeler.yml @@ -32,7 +32,7 @@ jobs: pull-requests: write # pulls.get/list plus add/remove the label on PRs steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index d4271c6aad..674822363b 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 97c227f23c..9da49ad7ae 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -48,7 +48,7 @@ jobs: echo "version=${VERSION}" >> $GITHUB_OUTPUT - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index db67c9435d..844a77b489 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -63,7 +63,7 @@ jobs: RUN_ENGINE: ${{ github.event_name != 'workflow_dispatch' || inputs.build_engine }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/rollback-latest.yml b/.github/workflows/rollback-latest.yml index 3e49f727c6..fb0289b5a0 100644 --- a/.github/workflows/rollback-latest.yml +++ b/.github/workflows/rollback-latest.yml @@ -19,7 +19,7 @@ jobs: packages: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index f5b9447720..dbda06764b 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index af255de3cf..2033154a00 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1bfc94be5b..1f53edd17b 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/sync-portal-docs.yml b/.github/workflows/sync-portal-docs.yml index 636cd5f2e1..a6073b3f1f 100644 --- a/.github/workflows/sync-portal-docs.yml +++ b/.github/workflows/sync-portal-docs.yml @@ -33,7 +33,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index 6b436ed2f6..a199fd6cbd 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 4e9eddd614..3b7a0b04fa 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -72,7 +72,7 @@ jobs: matrix: ${{ steps.set-matrix.outputs.matrix }} steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -134,7 +134,7 @@ jobs: SIGN_BUNDLE: ${{ inputs.sign && (matrix.platform == 'macos-15' && secrets.APPLE_CERTIFICATE != '' || github.ref == 'refs/heads/main') }} steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -703,7 +703,7 @@ jobs: pull-requests: write steps: - name: Harden the runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -795,7 +795,7 @@ jobs: if: always() steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index dfdd0e6c62..4717c9c387 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -34,7 +34,7 @@ jobs: timeout-minutes: 20 steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -77,7 +77,7 @@ jobs: cache-scope: stirling-pdf-fat steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit @@ -217,7 +217,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit diff --git a/.github/workflows/update-gradle.yml b/.github/workflows/update-gradle.yml index 72b347e62f..07474f112b 100644 --- a/.github/workflows/update-gradle.yml +++ b/.github/workflows/update-gradle.yml @@ -19,7 +19,7 @@ jobs: timeout-minutes: 20 steps: - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit From 79686a3a09bc133df7822d82d413742e6feaad26 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:54 +0000 Subject: [PATCH 10/10] form field editing (#6655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Building ontop of a users draft PR for form creation tools **Fill Form** becomes a full **Form Editor**: fill, create, modify and delete AcroForm fields visually. Builds on the community form-creation draft, plus a UX/UI rework pass. - **Backend**: `/api/v1/form` endpoints — `fields-with-coordinates`, `add/modify/delete-fields`, combined `edit-fields` (one round-trip), `fill`, `extract-csv/xlsx`; supports text (multiline, comb), checkbox, dropdown, list box, radio, button actions (reset/print/URL/submit) and signature placeholders - **Create**: type palette, click-or-drag placement with snap guides, inline property editor, batch "Add N fields" - **Modify**: move/resize on the page, arrow-nudge + Delete key, X/Y/W/H inputs, staged edits/deletes with chips, discard - **Fill**: live progress + required tracking, flatten toggle, Export menu (JSON/CSV/XLSX), Ctrl/Cmd+S - **Safety**: confirm dialog before discarding staged work; empty required fields warn with "Save anyway" instead of blocking - **UI**: consistent panel skeleton (fixed header / scrolling list / pinned actions), empty states that link into Create, full i18n with plural keys [walkthrough.html](https://github.com/user-attachments/files/30508976/walkthrough.html) --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: Denys Vitali Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 2 + .../model/FormFieldWithCoordinates.java | 16 + .../common/util/FormFieldTypeSupport.java | 118 ++ .../software/common/util/FormUtils.java | 1636 ++++++++++++++++- .../util/ChoiceOptionRoundTripTest.java | 116 ++ .../common/util/DeepFieldTreeTest.java | 62 + .../common/util/FormEditSafetyTest.java | 201 ++ .../common/util/FormFieldNameSafetyTest.java | 61 + .../common/util/FormFieldTypeSupportTest.java | 10 +- .../util/FormUtilsEditRegressionTest.java | 911 +++++++++ .../util/FormUtilsEditReportingTest.java | 118 ++ .../common/util/FormUtilsEditingTest.java | 467 +++++ .../common/util/FormUtilsGapTest.java | 16 +- .../common/util/FormUtilsMoreTest.java | 57 +- .../common/util/FormUtilsNoAcroFormTest.java | 102 + .../util/FormUtilsRadioCaptionTest.java | 175 ++ .../util/MissingDefaultResourcesTest.java | 57 + .../software/SPDF/config/WebMvcConfig.java | 12 +- .../api/form/FormFillController.java | 290 ++- .../api/form/FormPayloadParser.java | 39 + .../api/form/FormFieldBundleTest.java | 374 ++++ .../api/form/FormFillControllerTest.java | 307 ++++ .../api/form/FormPayloadParserTest.java | 88 + .../configuration/SecurityConfiguration.java | 4 +- .../saas/security/SupabaseSecurityConfig.java | 6 +- .../public/locales/en-US/translation.toml | 84 +- frontend/editor/public/og-metadata.json | 4 +- frontend/editor/public/og-metadata.saas.json | 4 +- frontend/editor/scripts/lint/theme-lint.mjs | 2 +- .../shared/NavigationWarningModal.tsx | 86 +- .../shared/UnsavedChangesDialog.tsx | 108 ++ .../core/components/viewer/EmbedPdfViewer.tsx | 6 +- .../core/components/viewer/LocalEmbedPDF.tsx | 29 + .../viewer/SignatureFieldOverlay.tsx | 11 +- .../viewer/useViewerWorkbenchBarButtons.tsx | 2 +- .../core/data/useTranslatedToolRegistry.tsx | 18 +- .../core/tests/helpers/multipart-capture.ts | 94 + .../tests/stubbed/form-field-editing.spec.ts | 474 +++++ .../formFill/ButtonAppearanceOverlay.tsx | 62 +- .../formFill/FormCreationInteractionLock.tsx | 26 + .../tools/formFill/FormFieldCreatePanel.tsx | 326 ++++ .../formFill/FormFieldCreationOverlay.tsx | 318 ++++ .../tools/formFill/FormFieldEditOverlay.tsx | 792 ++++++++ .../tools/formFill/FormFieldModifyPanel.tsx | 473 +++++ .../core/tools/formFill/FormFieldOverlay.tsx | 52 +- .../formFill/FormFieldPropertyEditor.tsx | 556 ++++++ .../core/tools/formFill/FormFill.module.css | 85 +- .../src/core/tools/formFill/FormFill.tsx | 274 ++- .../tools/formFill/FormFillContext.test.tsx | 340 ++++ .../core/tools/formFill/FormFillContext.tsx | 540 +++++- .../core/tools/formFill/SkippedEditsAlert.tsx | 54 + .../src/core/tools/formFill/SnapGuides.tsx | 41 + .../__fixtures__/checkbox-and-radio.zip | Bin 0 -> 3150 bytes .../formFill/__fixtures__/many-fields.zip | Bin 0 -> 43006 bytes .../formFill/fieldBundle.fixtures.test.ts | 56 + .../core/tools/formFill/fieldBundle.test.ts | 241 +++ .../src/core/tools/formFill/fieldBundle.ts | 177 ++ .../editor/src/core/tools/formFill/formApi.ts | 126 +- .../tools/formFill/formCoordinateUtils.ts | 187 ++ .../core/tools/formFill/formFieldColors.ts | 22 + .../tools/formFill/formFieldMerge.test.ts | 67 + .../src/core/tools/formFill/formFieldMerge.ts | 30 + .../src/core/tools/formFill/formFillEvents.ts | 13 + .../src/core/tools/formFill/formSnapUtils.ts | 130 ++ .../core/tools/formFill/pendingSelection.ts | 20 + .../core/tools/formFill/radioLayout.test.ts | 40 + .../editor/src/core/tools/formFill/types.ts | 119 ++ .../core/tools/formFill/useFieldShortcuts.ts | 138 ++ .../src/core/tools/formFill/useFormCommit.ts | 64 + .../core/tools/formFill/usePageScale.test.ts | 103 ++ .../src/core/tools/formFill/usePageScale.ts | 92 + 71 files changed, 11389 insertions(+), 342 deletions(-) create mode 100644 app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java create mode 100644 app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java create mode 100644 frontend/editor/src/core/components/shared/UnsavedChangesDialog.tsx create mode 100644 frontend/editor/src/core/tests/helpers/multipart-capture.ts create mode 100644 frontend/editor/src/core/tests/stubbed/form-field-editing.spec.ts create mode 100644 frontend/editor/src/core/tools/formFill/FormCreationInteractionLock.tsx create mode 100644 frontend/editor/src/core/tools/formFill/FormFieldCreatePanel.tsx create mode 100644 frontend/editor/src/core/tools/formFill/FormFieldCreationOverlay.tsx create mode 100644 frontend/editor/src/core/tools/formFill/FormFieldEditOverlay.tsx create mode 100644 frontend/editor/src/core/tools/formFill/FormFieldModifyPanel.tsx create mode 100644 frontend/editor/src/core/tools/formFill/FormFieldPropertyEditor.tsx create mode 100644 frontend/editor/src/core/tools/formFill/FormFillContext.test.tsx create mode 100644 frontend/editor/src/core/tools/formFill/SkippedEditsAlert.tsx create mode 100644 frontend/editor/src/core/tools/formFill/SnapGuides.tsx create mode 100644 frontend/editor/src/core/tools/formFill/__fixtures__/checkbox-and-radio.zip create mode 100644 frontend/editor/src/core/tools/formFill/__fixtures__/many-fields.zip create mode 100644 frontend/editor/src/core/tools/formFill/fieldBundle.fixtures.test.ts create mode 100644 frontend/editor/src/core/tools/formFill/fieldBundle.test.ts create mode 100644 frontend/editor/src/core/tools/formFill/fieldBundle.ts create mode 100644 frontend/editor/src/core/tools/formFill/formCoordinateUtils.ts create mode 100644 frontend/editor/src/core/tools/formFill/formFieldColors.ts create mode 100644 frontend/editor/src/core/tools/formFill/formFieldMerge.test.ts create mode 100644 frontend/editor/src/core/tools/formFill/formFieldMerge.ts create mode 100644 frontend/editor/src/core/tools/formFill/formFillEvents.ts create mode 100644 frontend/editor/src/core/tools/formFill/formSnapUtils.ts create mode 100644 frontend/editor/src/core/tools/formFill/pendingSelection.ts create mode 100644 frontend/editor/src/core/tools/formFill/radioLayout.test.ts create mode 100644 frontend/editor/src/core/tools/formFill/useFieldShortcuts.ts create mode 100644 frontend/editor/src/core/tools/formFill/useFormCommit.ts create mode 100644 frontend/editor/src/core/tools/formFill/usePageScale.test.ts create mode 100644 frontend/editor/src/core/tools/formFill/usePageScale.ts diff --git a/.gitignore b/.gitignore index 4b34350f67..1290056e05 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,8 @@ app/core/src/main/resources/static/images/google-drive.svg *.nar *.ear *.zip +# Real backend archives the form-bundle reader is tested against. +!frontend/editor/src/core/tools/formFill/__fixtures__/*.zip *.tar.gz *.rar *.db diff --git a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java index 54ccafd665..9a0f23aa33 100644 --- a/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java +++ b/app/common/src/main/java/stirling/software/common/model/FormFieldWithCoordinates.java @@ -62,6 +62,15 @@ public class FormFieldWithCoordinates { @Schema(description = "Widget coordinates on each page (fields can have multiple widgets)") private List widgets; + @Schema(description = "Maximum character count for a text field (/MaxLen); null when unset") + private Integer maxLength; + + @Schema( + description = + "Push button activation action as a spec string:" + + " 'reset', 'print', 'uri:' or 'submit:'") + private String buttonActionSpec; + /** * Coordinates for a single widget annotation (visual representation of the field). A field can * have multiple widgets if it appears on multiple pages. @@ -94,5 +103,12 @@ public class FormFieldWithCoordinates { @Schema(description = "Font size in PDF points") private Float fontSize; + + @Schema( + description = + "CropBox height in PDF points. Lets the frontend reverse the backend's" + + " Y-flip when sending new widget coordinates back for" + + " create/modify operations.") + private Float cropBoxHeight; } } diff --git a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java index 1f971d8d9e..5d833290ec 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java +++ b/app/common/src/main/java/stirling/software/common/util/FormFieldTypeSupport.java @@ -3,14 +3,20 @@ package stirling.software.common.util; import java.io.IOException; import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.function.Function; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionNamed; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionResetForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionSubmitForm; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; @@ -59,6 +65,24 @@ public enum FormFieldTypeSupport { List options) throws IOException { PDTextField textField = (PDTextField) field; + if (definition.fontSize() != null && definition.fontSize() > 0) { + textField.setDefaultAppearance("/Helv " + definition.fontSize() + " Tf 0 g"); + } + if (Boolean.TRUE.equals(definition.multiline())) { + textField.setMultiline(true); + } + // Comb field: evenly spaced character cells (e.g. SSN, phone). Requires + // a positive MaxLen and is mutually exclusive with multiline. + if (definition.maxLength() != null && definition.maxLength() > 0) { + textField.setMaxLen(definition.maxLength()); + if (!Boolean.TRUE.equals(definition.multiline())) { + try { + textField.setComb(true); + } catch (Exception e) { + log.debug("Unable to set comb flag: {}", e.getMessage()); + } + } + } String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse(""); if (!defaultValue.isBlank()) { FormUtils.setTextValue(textField, defaultValue); @@ -272,14 +296,108 @@ public enum FormFieldTypeSupport { PDTerminalField createField(PDAcroForm acroForm) { return new PDSignatureField(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + // Empty signature placeholder: no value to apply (signed later by a sign tool). }, BUTTON("button", "pushButton", PDPushButton.class) { @Override PDTerminalField createField(PDAcroForm acroForm) { return new PDPushButton(acroForm); } + + @Override + boolean doesNotsupportsDefinitionCreation() { + return false; + } + + @Override + void applyNewFieldDefinition( + PDTerminalField field, + FormUtils.NewFormFieldDefinition definition, + List options) + throws IOException { + if (field.getWidgets().isEmpty()) { + return; + } + PDAnnotationWidget widget = field.getWidgets().get(0); + + // Visible caption (/MK /CA). + String caption = definition.label(); + if (caption == null || caption.isBlank()) { + caption = definition.name(); + } + if (caption != null && !caption.isBlank()) { + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + if (mk == null) { + mk = new PDAppearanceCharacteristicsDictionary(widget.getCOSObject()); + widget.setAppearanceCharacteristics(mk); + } + mk.setNormalCaption(caption); + } + widget.setPrinted(true); + + applyButtonAction(widget, definition.buttonAction()); + } }; + /** + * Writes a push button's activation action from a "reset"/"print"/"uri:"/"submit:" spec, + * returning why it could not, or null on success. A blank spec clears the action. + */ + public static String applyButtonAction(PDAnnotationWidget widget, String action) { + if (action == null) { + return null; + } + if (action.isBlank()) { + // An explicit blank clears the action rather than leaving the old one behind. + widget.getCOSObject().removeItem(COSName.A); + return null; + } + String spec = action.trim(); + if (!ACTION_SPEC.matcher(spec).matches()) { + return "'" + action + "' is not a button action this editor understands"; + } + // The editor emits "uri:" the moment that kind is picked, before a URL is typed; an + // empty target is not yet an action, so clear rather than write an inert one. + int colon = spec.indexOf(':'); + if (colon >= 0 && spec.substring(colon + 1).isBlank()) { + widget.getCOSObject().removeItem(COSName.A); + return null; + } + try { + String lower = spec.toLowerCase(Locale.ROOT); + if (lower.equals("reset")) { + widget.getCOSObject().setItem(COSName.A, new PDActionResetForm().getCOSObject()); + } else if (lower.equals("print")) { + PDActionNamed named = new PDActionNamed(); + named.setN("Print"); + widget.getCOSObject().setItem(COSName.A, named.getCOSObject()); + } else if (lower.startsWith("uri:")) { + PDActionURI uri = new PDActionURI(); + uri.setURI(spec.substring(4)); + widget.getCOSObject().setItem(COSName.A, uri.getCOSObject()); + } else if (lower.startsWith("submit:")) { + PDActionSubmitForm submit = new PDActionSubmitForm(); + // Store the target URL on the action dictionary's /F entry. + submit.getCOSObject().setString(COSName.F, spec.substring(7)); + widget.getCOSObject().setItem(COSName.A, submit.getCOSObject()); + } + return null; + } catch (Exception e) { + log.debug("Unable to apply button action '{}': {}", action, e.getMessage()); + return e.getMessage(); + } + } + + /** The spec forms applyButtonAction understands; anything else is reported, not dropped. */ + private static final Pattern ACTION_SPEC = + Pattern.compile( + "^(reset|print|uri:.*|submit:.*)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + private static final Map BY_TYPE = Arrays.stream(values()) .collect( diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index a1862d379c..401b1eb1ac 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -23,6 +23,7 @@ import org.apache.pdfbox.cos.COSArray; import org.apache.pdfbox.cos.COSBase; import org.apache.pdfbox.cos.COSDictionary; import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSString; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; @@ -32,10 +33,12 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.graphics.color.PDColor; import org.apache.pdfbox.pdmodel.graphics.image.JPEGFactory; import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; @@ -68,6 +71,12 @@ public class FormUtils { public final Set CHOICE_FIELD_TYPES = Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO); + /** The reserved off-state name every toggle widget must carry an appearance for. */ + private final String OFF_STATE = "Off"; + + /** The on-state a checkbox gets when the definition supplies no export values. */ + private final String DEFAULT_CHECKBOX_ON_STATE = "Yes"; + /** * Threshold in PDF points for considering two widgets to be on the same line. Fields whose * y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead @@ -75,6 +84,9 @@ public class FormUtils { */ private static final float SAME_LINE_THRESHOLD_PT = 10.0f; + /** Below this, a rect change is a no-op and the existing /AP still maps exactly. */ + private static final float GEOMETRY_EPSILON_PT = 0.01f; + private static final Pattern HEX_UUID_PATTERN = Pattern.compile("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"); private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); @@ -236,6 +248,8 @@ public class FormUtils { .multiline(multiline) .tooltip(tooltip) .widgets(widgets.isEmpty() ? null : widgets) + .maxLength(extractMaxLength(terminalField)) + .buttonActionSpec(extractButtonAction(terminalField)) .build()); } @@ -300,7 +314,8 @@ public class FormUtils { findPageIndexForAnnotation(document, fieldDict, annotationPageMap); if (pageIndex >= 0) { PDRectangle rectangle = new PDRectangle(rectArray); - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, null, field)); } else { @@ -373,7 +388,8 @@ public class FormUtils { } } - result.add( + addWidget( + result, createWidgetCoordinates( document, rectangle, pageIndex, exportValue, field)); } catch (Exception e) { @@ -387,6 +403,15 @@ public class FormUtils { return result; } + /** Unreadable geometry yields null, which must never reach the list the comparator walks. */ + private void addWidget( + List target, + FormFieldWithCoordinates.WidgetCoordinates widget) { + if (widget != null) { + target.add(widget); + } + } + private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates( PDDocument document, PDRectangle rectangle, @@ -424,13 +449,14 @@ public class FormUtils { float finalW = width; float finalH = height; - // Validate coordinates are within reasonable bounds - if (finalX < -1.0f - || finalY < -1.0f - || finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow - || finalY > cropHeight + 1.0f) { + // Only nonsense is rejected. A widget outside the visible page is legal and must still be + // reported, or the field loses its geometry and the user cannot drag it back. + if (!Float.isFinite(finalX) + || !Float.isFinite(finalY) + || !Float.isFinite(finalW) + || !Float.isFinite(finalH)) { log.warn( - "Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}", + "Widget coordinates are not finite for field '{}': page={}, x={}, y={}, w={}, h={}", field.getFullyQualifiedName(), pageIndex, finalX, @@ -439,6 +465,12 @@ public class FormUtils { finalH); return null; } + if (finalX < 0 || finalY < 0 || finalX > cropBox.getWidth() || finalY > cropHeight) { + log.debug( + "Widget for field '{}' sits outside page {}", + field.getFullyQualifiedName(), + pageIndex); + } return FormFieldWithCoordinates.WidgetCoordinates.builder() .pageIndex(pageIndex) @@ -448,6 +480,7 @@ public class FormUtils { .height(finalH) .exportValue(exportValue) .fontSize(extractFontSize(field)) + .cropBoxHeight(cropHeight) .build(); } @@ -459,12 +492,40 @@ public class FormUtils { * * @param document PDF document to repair */ + /** + * PDFBox reads /Opt entries without following references, so an option stored indirectly - as + * real forms do - silently disappears. Resolving in place keeps the value and the reader + * honest. + */ + private void resolveIndirectChoiceOptions(PDAcroForm acroForm) { + try { + for (PDField field : acroForm.getFieldTree()) { + if (!(field instanceof PDChoice)) { + continue; + } + COSBase raw = field.getCOSObject().getDictionaryObject(COSName.OPT); + if (!(raw instanceof COSArray options)) { + continue; + } + for (int i = 0; i < options.size(); i++) { + COSBase resolved = options.getObject(i); + if (resolved != null && resolved != options.get(i)) { + options.set(i, resolved); + } + } + } + } catch (Exception e) { + log.debug("Could not resolve indirect choice options: {}", e.getMessage()); + } + } + public void repairMissingWidgetPageReferences(PDDocument document) { try { PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { return; } + resolveIndirectChoiceOptions(acroForm); log.debug("Checking for widgets with missing page references..."); int repairedCount = 0; @@ -887,7 +948,9 @@ public class FormUtils { } PDFont helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA); try { - // Map standard name used by many DAs + // Both spellings: a DA naming a font missing from /DR makes + // refreshAppearances throw for the whole form, not just that field. + dr.put(COSName.getPDFName("Helv"), helvetica); dr.put(COSName.getPDFName("Helvetica"), helvetica); } catch (Exception ignore) { try { @@ -991,7 +1054,7 @@ public class FormUtils { try { textField.setValue(value != null ? value : ""); return; - } catch (IOException initial) { + } catch (IOException | RuntimeException initial) { log.debug( "Primary fill failed for text field '{}': {}", textField.getFullyQualifiedName(), @@ -1277,6 +1340,11 @@ public class FormUtils { } return String.join(",", selected); } + // A signature has no text value; getValueAsString would emit a JVM identity hash that + // changes on every load, so the same document would describe itself differently. + if (field instanceof PDSignatureField) { + return null; + } return field.getValueAsString(); } catch (Exception e) { log.debug( @@ -1451,16 +1519,26 @@ public class FormUtils { return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // An option only beats the name when the name is auto-generated; a human-typed + // one wins below, so a group named "Choice" does not read as its first option. + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.getFirst()); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1497,6 +1575,27 @@ public class FormUtils { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for auto-generated identifiers ("Field_5", "t3", UUIDs). Unlike {@link + * #looksGeneric} it keeps human-typed placeholders like "Choice", which are usable labels. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; @@ -1513,35 +1612,62 @@ public class FormUtils { public void modifyFormFields( PDDocument document, List modifications) { + modifyFormFields(document, modifications, null); + } + + public void modifyFormFields( + PDDocument document, + List modifications, + List skipped) { if (document == null || modifications == null || modifications.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot modify fields because the document has no AcroForm"); + for (ModifyFormFieldDefinition modification : modifications) { + if (modification != null) { + recordSkip( + skipped, + "modify", + modification.targetName(), + "the document has no form to modify"); + } + } return; } Set existingNames = collectExistingFieldNames(acroForm); for (ModifyFormFieldDefinition modification : modifications) { - if (modification == null || modification.targetName() == null) { + if (modification == null) { continue; } - String lookupName = modification.targetName().trim(); + String lookupName = + modification.targetName() == null ? "" : modification.targetName().trim(); if (lookupName.isEmpty()) { + recordSkip(skipped, "modify", null, "the request named no field to change"); + continue; + } + + String nameProblem = renameProblem(lookupName, modification.name()); + if (nameProblem != null) { + log.warn("Rejecting rename of '{}': {}", sanitizeForLog(lookupName), nameProblem); + recordSkip(skipped, "modify", lookupName, nameProblem); continue; } PDField originalField = locateField(acroForm, lookupName); if (originalField == null) { log.warn("No matching field '{}' found for modification", lookupName); + recordSkip(skipped, "modify", lookupName, "no field with that name exists"); continue; } List widgets = originalField.getWidgets(); if (widgets == null || widgets.isEmpty()) { log.warn("Field '{}' has no widgets; skipping modification", lookupName); + recordSkip(skipped, "modify", lookupName, "the field has nothing drawn on a page"); continue; } @@ -1552,6 +1678,8 @@ public class FormUtils { log.warn( "Unable to resolve widget page or rectangle for '{}'; skipping", lookupName); + recordSkip( + skipped, "modify", lookupName, "the field is not placed on a known page"); continue; } @@ -1564,6 +1692,11 @@ public class FormUtils { .getSupportedNewFieldTypes() .contains(resolvedType)) { log.warn("Unsupported target type '{}' for field '{}'", resolvedType, lookupName); + recordSkip( + skipped, + "modify", + lookupName, + "'" + abbreviate(resolvedType, 60) + "' is not a supported field type"); continue; } @@ -1571,13 +1704,22 @@ public class FormUtils { Optional.ofNullable(modification.name()) .map(String::trim) .filter(s -> !s.isEmpty()) + // The editor seeds the box with the qualified name, so a submission + // equal to it is not a rename; keep the field's own partial name. + .filter(name -> !name.equals(lookupName)) + .map(name -> leafName(lookupName, name)) .orElseGet(originalField::getPartialName); + String qualified = originalField.getFullyQualifiedName(); + // desiredName is a PARTIAL name but existingNames holds qualified ones, so compare + // under this field's own parent or siblings collide unnoticed. + String prefix = parentPrefix(qualified); + String reservedName = null; if (desiredName != null) { - existingNames.remove(originalField.getFullyQualifiedName()); - existingNames.remove(originalField.getPartialName()); - desiredName = generateUniqueFieldName(desiredName, existingNames); - existingNames.add(desiredName); + existingNames.remove(qualified); + desiredName = generateUniqueFieldName(desiredName, existingNames, prefix); + reservedName = prefix + desiredName; + existingNames.add(reservedName); } // Try to modify field in-place first for simple property changes @@ -1586,17 +1728,27 @@ public class FormUtils { if (!typeChanging) { try { - modifyFieldPropertiesInPlace(originalField, modification, desiredName); + modifyFieldPropertiesInPlace( + document, originalField, modification, desiredName, skipped); log.debug("Successfully modified field '{}' in-place", lookupName); continue; // Skip the remove-and-recreate process } catch (Exception e) { log.debug( "In-place modification failed for '{}', falling back to recreation: {}", - lookupName, + sanitizeForLog(lookupName), e.getMessage()); } } + // Recreation always builds a top-level field, so running it on a field nested under a + // parent would silently move it out of that parent and change its qualified name. + if (!prefix.isEmpty()) { + log.warn("Cannot recreate nested field '{}'; leaving it as it was", lookupName); + recordSkip(skipped, "modify", lookupName, refusalReason(typeChanging)); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + // For type changes or when in-place modification fails, use remove-and-recreate // But create the new field first to ensure success before removing the original NewFormFieldDefinition replacementDefinition = @@ -1613,16 +1765,31 @@ public class FormUtils { modification.multiSelect(), modification.options(), modification.defaultValue(), - modification.tooltip()); + modification.tooltip(), + modification.fontSize(), + modification.readOnly(), + modification.multiline(), + modification.maxLength(), + modification.buttonAction()); List sanitizedOptions = sanitizeOptions(modification.options()); - try { - FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); - if (handler == null || handler.doesNotsupportsDefinitionCreation()) { - handler = FormFieldTypeSupport.TEXT; - } + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Falling back to a text field here would silently retype the field and report + // success, so refuse instead and leave the original alone. + recordSkip( + skipped, + "modify", + lookupName, + "'" + + resolvedType + + "' cannot be rebuilt, so the field was left as it was"); + releaseReservedName(existingNames, reservedName, qualified); + continue; + } + try { // Create new field first - if this fails, original field is preserved createNewField( handler, @@ -1635,25 +1802,56 @@ public class FormUtils { removeFieldFromDocument(document, acroForm, originalField); + // A rebuilt toggle has no /AP yet, so without this it renders blank and cannot + // tick. + applyButtonAppearances( + document, + acroForm, + List.of(Map.entry(prefix + desiredName, replacementDefinition))); + log.debug( "Successfully replaced field '{}' with type '{}'", - lookupName, + sanitizeForLog(lookupName), resolvedType); } catch (Exception e) { log.warn( "Failed to modify form field '{}' to type '{}': {}", - lookupName, + sanitizeForLog(lookupName), resolvedType, e.getMessage(), e); + recordSkip(skipped, "modify", lookupName, readableFailure(e)); + releaseReservedName(existingNames, reservedName, qualified); } } ensureAppearances(acroForm); } + /** Nothing was applied, so give the field back its real name and drop the one we reserved. */ + private void releaseReservedName( + Set existingNames, String reservedName, String originalQualifiedName) { + if (reservedName != null) { + existingNames.remove(reservedName); + } + if (originalQualifiedName != null) { + existingNames.add(originalQualifiedName); + } + } + + /** Why the edit was refused, which is not always the type change that triggered the path. */ + private String refusalReason(boolean typeChanging) { + return typeChanging + ? "a field nested under a parent cannot have its type changed here" + : "this change needs the field rebuilt, which a nested field does not support"; + } + private void modifyFieldPropertiesInPlace( - PDField field, ModifyFormFieldDefinition modification, String newName) + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + String newName, + List skipped) throws IOException { if (newName != null && !newName.equals(field.getPartialName())) { field.setPartialName(newName); @@ -1690,6 +1888,14 @@ public class FormUtils { if (modification.multiSelect() != null) { choiceField.setMultiSelect(modification.multiSelect()); } + } else if (modification.options() != null && !(field instanceof PDChoice)) { + // Only a choice field stores an option list, so say so rather than drop the edit and + // let the panel report it as saved. + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "only dropdown and list fields have an editable option list"); } // Update tooltip on widgets @@ -1703,6 +1909,318 @@ public class FormUtils { } } } + + // Update read-only flag + if (modification.readOnly() != null) { + field.setReadOnly(modification.readOnly()); + } + + // Update multiline flag (text fields only) + if (modification.multiline() != null && field instanceof PDTextField tf) { + tf.setMultiline(modification.multiline()); + } + + // Update the activation action (push buttons only) + if (modification.buttonAction() != null && field instanceof PDPushButton) { + String actionProblem = null; + for (PDAnnotationWidget widget : field.getWidgets()) { + String problem = + FormFieldTypeSupport.applyButtonAction(widget, modification.buttonAction()); + if (problem != null && actionProblem == null) { + actionProblem = problem; + } + } + if (actionProblem != null) { + recordSkip(skipped, "modify", field.getFullyQualifiedName(), actionProblem); + } + } + + // Update comb / max length (text fields only). Zero clears it, since a null means + // "unchanged" and the editor otherwise has no way to remove an existing /MaxLen. + if (modification.maxLength() != null && field instanceof PDTextField combTf) { + int maxLength = modification.maxLength(); + if (maxLength > 0) { + combTf.setMaxLen(maxLength); + if (!combTf.isMultiline()) { + try { + combTf.setComb(true); + } catch (Exception ignore) { + // comb is best-effort + } + } + } else { + combTf.getCOSObject().removeItem(COSName.MAX_LEN); + try { + combTf.setComb(false); + } catch (Exception ignore) { + // comb is best-effort + } + } + } + + // Update font size (variable-text fields only: text/combo/list) + if (modification.fontSize() != null + && modification.fontSize() > 0 + && field instanceof PDVariableText vt) { + applyFontSizeToDefaultAppearance(vt, modification.fontSize()); + // Clear the cached appearance so ensureAppearances() regenerates it + // with the new font size; otherwise viewers keep the old glyph sizing. + removeWidgetAppearanceStreams(field); + } + + // Incoming coordinates are CropBox-relative and lower-left-origin, the reverse + // of what createWidgetCoordinates extracts. + if (modification.x() != null + || modification.y() != null + || modification.width() != null + || modification.height() != null + || modification.optionGap() != null + || modification.optionSize() != null) { + updateWidgetGeometry(document, field, modification, skipped); + } + } + + /** + * Moves/resizes a field's widgets. The rect describes widget 0; the rest shift by the same + * delta and keep their own size, so a radio group travels intact instead of being normalised. + */ + /** A size PDFBox would write as "Infinity" or a zero-area box makes the field unusable. */ + private static boolean unusableSize(Float value) { + return value != null && (!Float.isFinite(value) || value <= 0); + } + + /** The rectangle enclosing every widget, or null when none has one. */ + private static PDRectangle widgetBounds(List widgets) { + float minX = Float.MAX_VALUE; + float minY = Float.MAX_VALUE; + float maxX = -Float.MAX_VALUE; + float maxY = -Float.MAX_VALUE; + boolean any = false; + for (PDAnnotationWidget widget : widgets) { + PDRectangle r = widget.getRectangle(); + if (r == null) continue; + any = true; + minX = Math.min(minX, r.getLowerLeftX()); + minY = Math.min(minY, r.getLowerLeftY()); + maxX = Math.max(maxX, r.getUpperRightX()); + maxY = Math.max(maxY, r.getUpperRightY()); + } + return any ? new PDRectangle(minX, minY, maxX - minX, maxY - minY) : null; + } + + private void updateWidgetGeometry( + PDDocument document, + PDField field, + ModifyFormFieldDefinition modification, + List skipped) { + List widgets = field.getWidgets(); + if (widgets == null || widgets.isEmpty()) { + return; + } + if (unusableSize(modification.width()) || unusableSize(modification.height())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "a width and height above zero are required, so the size was left as it was"); + return; + } + if (modification.x() != null && !Float.isFinite(modification.x()) + || modification.y() != null && !Float.isFinite(modification.y())) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + "the position is not a usable number, so the field was left where it was"); + return; + } + PDAnnotationWidget anchor = widgets.get(0); + PDRectangle anchorRect = anchor.getRectangle(); + if (anchorRect == null) { + return; + } + + Map pageMap = buildAnnotationPageMap(document); + int anchorPage = determineWidgetPageIndex(document, anchor, pageMap); + float offX = 0; + float offY = 0; + if (anchorPage >= 0) { + PDRectangle cropBox = document.getPage(anchorPage).getCropBox(); + offX = cropBox.getLowerLeftX(); + offY = cropBox.getLowerLeftY(); + } + + float newX = + modification.x() != null ? modification.x() + offX : anchorRect.getLowerLeftX(); + float newY = + modification.y() != null ? modification.y() + offY : anchorRect.getLowerLeftY(); + float dx = newX - anchorRect.getLowerLeftX(); + float dy = newY - anchorRect.getLowerLeftY(); + Float newW = modification.width(); + Float newH = modification.height(); + + // Spacing and size are a property of the whole group, so an explicit change re-flows + // every option. Gated on those two: a plain drag sends widget 0's size, which would be + // mistaken for the group's height and collapse the stack. + if ((modification.optionGap() != null || modification.optionSize() != null) + && field instanceof PDRadioButton + && widgets.size() > 1) { + PDRectangle bounds = widgetBounds(widgets); + if (bounds != null) { + PDRectangle box = + new PDRectangle( + bounds.getLowerLeftX() + dx, + bounds.getLowerLeftY() + dy, + modification.width() != null + ? modification.width() + : bounds.getWidth(), + modification.height() != null + ? modification.height() + : bounds.getHeight()); + List reflowed = + radioOptionRects( + box, + widgets.size(), + modification.optionGap(), + modification.optionSize()); + List groupStates = currentWidgetOnStates((PDButton) field); + for (int i = 0; i < widgets.size(); i++) { + widgets.get(i).setRectangle(reflowed.get(i)); + rebuildWidgetAppearance(document, field, widgets.get(i), i, groupStates, true); + } + return; + } + } + + // Read the on-states off /AP /N before the strip: PDFBox derives a button's + // value vocabulary from those keys, so a guessed state orphans /V. + List onStates = + field instanceof PDButton button ? currentWidgetOnStates(button) : List.of(); + boolean isRadio = field instanceof PDRadioButton; + + int leftOffPage = 0; + for (int i = 0; i < widgets.size(); i++) { + PDAnnotationWidget widget = widgets.get(i); + PDRectangle rect = widget.getRectangle(); + if (rect == null) { + continue; + } + if (i > 0 && determineWidgetPageIndex(document, widget, pageMap) != anchorPage) { + // A delta measured in another page's user space means nothing here. + log.warn( + "Field '{}' widget {} sits on a different page; geometry left alone", + field.getFullyQualifiedName(), + i); + leftOffPage++; + continue; + } + // Only the widget the request describes takes the new size; the rest keep theirs. + float targetW = i == 0 && newW != null ? newW : rect.getWidth(); + float targetH = i == 0 && newH != null ? newH : rect.getHeight(); + boolean resized = + Math.abs(targetW - rect.getWidth()) > GEOMETRY_EPSILON_PT + || Math.abs(targetH - rect.getHeight()) > GEOMETRY_EPSILON_PT; + widget.setRectangle( + new PDRectangle( + rect.getLowerLeftX() + dx, + rect.getLowerLeftY() + dy, + targetW, + targetH)); + // A pure translation re-maps the same /AP onto the new /Rect unchanged, but a toggle + // with no /AP at all has no on-state vocabulary and must be given one regardless. + boolean toggle = field instanceof PDCheckBox || field instanceof PDRadioButton; + if (resized || (toggle && normalAppearanceOnState(widget) == null)) { + rebuildWidgetAppearance(document, field, widget, i, onStates, isRadio); + } + } + // One row per field, not per widget, so a split group cannot fill the report on its own. + if (leftOffPage > 0) { + recordSkip( + skipped, + "modify", + field.getFullyQualifiedName(), + leftOffPage + " widget(s) on another page were left where they were"); + } + } + + /** After a resize, rebuilds the appearance PDFBox cannot regenerate by itself. */ + private void rebuildWidgetAppearance( + PDDocument document, + PDField field, + PDAnnotationWidget widget, + int index, + List onStates, + boolean isRadio) { + if (field instanceof PDSignatureField) { + // PDFBox never rebuilds a signature appearance; dropping it blanks the field. + return; + } + COSName priorState = widget.getCOSObject().getCOSName(COSName.AS); + try { + if (field instanceof PDCheckBox || field instanceof PDRadioButton) { + // Drop the stale streams: viewers stretch an /AP built for the old BBox + // onto the new /Rect, so a resized toggle looks distorted. + widget.getCOSObject().removeItem(COSName.AP); + String onState = + index < onStates.size() ? onStates.get(index) : DEFAULT_CHECKBOX_ON_STATE; + applyToggleAppearance(document, widget, onState, isRadio); + // applyToggleAppearance parks the widget on Off; put the selection back. + if (priorState != null && !OFF_STATE.equals(priorState.getName())) { + widget.getCOSObject().setName(COSName.AS, onState); + } + } else if (field instanceof PDPushButton) { + // /D and /R still carry the old BBox and cannot be regenerated, so drop them + // rather than leave a stretched down-state; viewers fall back to /N. + PDAppearanceDictionary existing = widget.getAppearance(); + if (existing != null) { + existing.getCOSObject().removeItem(COSName.D); + existing.getCOSObject().removeItem(COSName.R); + } + applyPushButtonAppearance(document, widget); + } else { + // text/choice: refreshAppearances() rebuilds these from /DA in ensureAppearances(). + widget.getCOSObject().removeItem(COSName.AP); + } + } catch (Exception e) { + log.warn( + "Could not rebuild the appearance for '{}' widget {}: {}", + field.getFullyQualifiedName(), + index, + e.getMessage()); + } + } + + /** Rewrites only the size token in a variable-text field's /DA, keeping font and colour. */ + private void applyFontSizeToDefaultAppearance(PDVariableText field, float fontSize) { + String da = field.getDefaultAppearance(); + if (da != null && !da.isBlank()) { + // Replace the size token (the operand immediately before "Tf"). + String[] tokens = da.split("\\s+"); + boolean replaced = false; + for (int i = 0; i < tokens.length; i++) { + if ("Tf".equals(tokens[i]) && i > 0) { + tokens[i - 1] = String.valueOf(fontSize); + replaced = true; + break; + } + } + if (replaced) { + field.setDefaultAppearance(String.join(" ", tokens)); + return; + } + } + field.setDefaultAppearance("/Helv " + fontSize + " Tf 0 g"); + } + + /** Drops cached /AP appearance streams from every widget of a field. */ + private void removeWidgetAppearanceStreams(PDField field) { + List widgets = field.getWidgets(); + if (widgets == null) { + return; + } + for (PDAnnotationWidget widget : widgets) { + widget.getCOSObject().removeItem(COSName.AP); + } } private String fallbackLabelForType(String type, int typeIndex) { @@ -1830,12 +2348,700 @@ public class FormUtils { return -1; } + /** + * Adds fields, creating an AcroForm if absent. Definition coordinates are CropBox-relative and + * lower-left-origin (the inverse of {@link #createWidgetCoordinates}). + */ + public void addNewFields(PDDocument document, List definitions) + throws IOException { + addNewFields(document, definitions, null); + } + + /** + * A form with variable-text fields needs /DR and /DA; PDFBox refuses to set a value without + * them, and a PDF that never had a form has neither. + */ + private void ensureAcroFormDefaults(PDAcroForm acroForm) { + if (acroForm == null) return; + try { + PDResources dr = acroForm.getDefaultResources(); + if (dr == null) { + dr = new PDResources(); + acroForm.setDefaultResources(dr); + } + String resourceName = "Helv"; + COSName alias = dr.add(new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + if (alias != null && alias.getName() != null && !alias.getName().isBlank()) { + resourceName = alias.getName(); + } + String da = acroForm.getDefaultAppearance(); + if (da == null || da.isBlank()) { + acroForm.setDefaultAppearance("/" + resourceName + " 12 Tf 0 g"); + } + } catch (Exception e) { + log.debug("Could not prepare AcroForm defaults: {}", e.getMessage()); + } + } + + public void addNewFields( + PDDocument document, + List definitions, + List skipped) + throws IOException { + if (document == null || definitions == null || definitions.isEmpty()) return; + // A page-less document has nowhere to put a widget; the clamp below cannot make it safe. + if (document.getNumberOfPages() == 0) { + log.warn("Cannot add form fields: document has no pages"); + for (NewFormFieldDefinition definition : definitions) { + if (definition != null) { + recordSkip(skipped, "add", definition.name(), "the document has no pages"); + } + } + return; + } + + PDAcroForm acroForm = getAcroFormSafely(document); + if (acroForm == null) { + // Create a new AcroForm for PDFs that don't have one yet + acroForm = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(acroForm); + } + ensureAcroFormDefaults(acroForm); + + Set existingNames = collectExistingFieldNames(acroForm); + int pageCount = document.getNumberOfPages(); + // Buttons need their appearances built after creation; see applyButtonAppearances. + List> createdButtons = new ArrayList<>(); + + for (NewFormFieldDefinition definition : definitions) { + if (definition == null) continue; + + String nameProblem = invalidFieldNameReason(definition.name()); + if (nameProblem != null) { + log.warn("Rejecting new field: {}", nameProblem); + recordSkip(skipped, "add", definition.name(), nameProblem); + continue; + } + + String resolvedType = + Optional.ofNullable(definition.type()) + .map(FormUtils::normalizeFieldType) + .orElse(FIELD_TYPE_TEXT); + + int pageIdx = definition.pageIndex() != null ? definition.pageIndex() : 0; + if (pageIdx < 0 || pageIdx >= pageCount) { + log.warn( + "Page index {} out of range (0-{}); clamping to last page", + pageIdx, + pageCount - 1); + // Clamped, so the field IS created; not a dropped edit and not reported as one. + pageIdx = Math.max(0, pageCount - 1); + } + PDPage page; + try { + page = document.getPage(pageIdx); + } catch (RuntimeException e) { + // getNumberOfPages() reports the raw /Count, which a broken /Pages tree can + // overstate, so the page may still not be there. + recordSkip( + skipped, + "add", + definition.name(), + "page " + (pageIdx + 1) + " could not be read from this PDF"); + continue; + } + PDRectangle cropBox = page.getCropBox(); + + // CropBox-relative, lower-left-origin -> absolute PDF user space. + float x = (definition.x() != null ? definition.x() : 0f) + cropBox.getLowerLeftX(); + float y = (definition.y() != null ? definition.y() : 0f) + cropBox.getLowerLeftY(); + float w = definition.width() != null ? definition.width() : 150f; + float h = definition.height() != null ? definition.height() : 20f; + PDRectangle rectangle = new PDRectangle(x, y, w, h); + + String baseName = + Optional.ofNullable(definition.name()) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .orElse("field"); + String uniqueName = generateUniqueFieldName(baseName, existingNames); + existingNames.add(uniqueName); + + List options = sanitizeOptions(definition.options()); + + try { + if (FIELD_TYPE_RADIO.equals(resolvedType)) { + // Radio is a single field with one widget per option; it can't go + // through the single-widget createNewField path. + createRadioField(acroForm, page, rectangle, uniqueName, definition, options); + } else { + FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(resolvedType); + if (handler == null || handler.doesNotsupportsDefinitionCreation()) { + // Quietly making it a text field reported success for a field the + // caller never asked for; say so instead. + recordSkip( + skipped, + "add", + uniqueName, + "'" + resolvedType + "' fields cannot be created"); + existingNames.remove(uniqueName); + continue; + } + createNewField( + handler, acroForm, page, rectangle, uniqueName, definition, options); + } + createdButtons.add(Map.entry(uniqueName, definition)); + } catch (Exception e) { + log.warn( + "Failed to create field '{}' of type '{}': {}", + sanitizeForLog(uniqueName), + resolvedType, + e.getMessage(), + e); + recordSkip(skipped, "add", uniqueName, readableFailure(e)); + } + } + + applyButtonAppearances(document, acroForm, createdButtons); + ensureAppearances(acroForm); + } + + /** + * {@link PDAcroForm#refreshAppearances()} never synthesizes /AP for the button family, and + * PDFBox reads a button's on-state from the /AP /N keys, so draw them then re-apply the value. + */ + private void applyButtonAppearances( + PDDocument document, + PDAcroForm acroForm, + List> created) { + for (Map.Entry entry : created) { + PDField field = acroForm.getField(entry.getKey()); + if (field instanceof PDPushButton) { + for (PDAnnotationWidget widget : field.getWidgets()) { + try { + applyPushButtonAppearance(document, widget); + } catch (Exception e) { + log.warn( + "Could not build an appearance for button '{}': {}", + entry.getKey(), + e.getMessage()); + } + } + continue; + } + if (!(field instanceof PDCheckBox) && !(field instanceof PDRadioButton)) { + continue; + } + boolean isRadio = field instanceof PDRadioButton; + List onStates = buttonOnStates((PDButton) field); + List widgets = field.getWidgets(); + for (int i = 0; i < widgets.size(); i++) { + String onState = i < onStates.size() ? onStates.get(i) : DEFAULT_CHECKBOX_ON_STATE; + try { + applyToggleAppearance(document, widgets.get(i), onState, isRadio); + } catch (Exception e) { + log.warn( + "Could not build an appearance for '{}' widget {}: {}", + entry.getKey(), + i, + e.getMessage()); + } + } + applyButtonDefault((PDButton) field, entry.getValue(), onStates); + } + } + + /** The on-state per widget: the export values when set, else the single checkbox state. */ + private List buttonOnStates(PDButton button) { + List exportValues = button.getExportValues(); + if (exportValues != null && !exportValues.isEmpty()) { + return exportValues; + } + return List.of(DEFAULT_CHECKBOX_ON_STATE); + } + + /** Each widget's live on-state, read from /AP /N before that dictionary is dropped. */ + private List currentWidgetOnStates(PDButton button) { + List exportValues = button.getExportValues(); + List widgets = button.getWidgets(); + // A checkbox's widgets all share one on-state, so /V names it however many there are. + // A radio's widgets each have their own, so /V identifies one and cannot stand in. + boolean sharedOnState = !(button instanceof PDRadioButton); + String fromValue = sharedOnState || widgets.size() == 1 ? nonOffValueName(button) : null; + List states = new ArrayList<>(widgets.size()); + for (int i = 0; i < widgets.size(); i++) { + String state = normalAppearanceOnState(widgets.get(i)); + if ((state == null || state.isEmpty()) + && exportValues != null + && i < exportValues.size()) { + state = sanitizePdfName(exportValues.get(i)); + } + if (state == null || state.isEmpty()) { + state = fromValue; + } + states.add(state == null || state.isEmpty() ? DEFAULT_CHECKBOX_ON_STATE : state); + } + return states; + } + + /** A button's current value when it names a real on-state, else null. */ + private String nonOffValueName(PDButton button) { + try { + // /V is inheritable, so walk up. A malformed PDF can point /Parent back at an + // ancestor, so track what we have seen rather than trusting the chain to end. + COSBase raw = null; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + COSDictionary d = button.getCOSObject(); + while (d != null && raw == null && seen.add(d)) { + raw = d.getDictionaryObject(COSName.V); + COSBase parent = d.getDictionaryObject(COSName.PARENT); + d = parent instanceof COSDictionary parentDict ? parentDict : null; + } + String name = + switch (raw) { + case COSName cosName -> cosName.getName(); + case COSString cosString -> cosString.getString(); + case null, default -> null; + }; + return name == null || name.isEmpty() || OFF_STATE.equals(name) ? null : name; + } catch (Exception e) { + log.debug("Could not read a button's value: {}", e.getMessage()); + return null; + } + } + + /** The first non-Off key of a widget's /AP /N sub-dictionary, or null. */ + private String normalAppearanceOnState(PDAnnotationWidget widget) { + try { + PDAppearanceDictionary appearance = widget.getAppearance(); + PDAppearanceEntry normal = appearance != null ? appearance.getNormalAppearance() : null; + if (normal == null || !normal.isSubDictionary()) { + return null; + } + for (COSName name : normal.getSubDictionary().keySet()) { + if (!OFF_STATE.equals(name.getName())) { + return name.getName(); + } + } + } catch (Exception e) { + log.debug("Could not read a widget's on-state: {}", e.getMessage()); + } + return null; + } + + /** Re-applies the definition's default now that the on-state keys exist to resolve it. */ + private void applyButtonDefault( + PDButton button, NewFormFieldDefinition definition, List onStates) { + try { + if (button instanceof PDCheckBox checkBox) { + if (isChecked(definition.defaultValue())) { + checkBox.check(); + } else { + checkBox.unCheck(); + } + return; + } + String requested = definition.defaultValue(); + if (requested == null || requested.isBlank()) { + return; + } + // The widget states are sanitized PDF names, so match the raw request against those. + String match = + onStates.stream() + .filter( + state -> + state.equals(requested) + || state.equalsIgnoreCase( + sanitizePdfName(requested))) + .findFirst() + .orElse(null); + if (match != null) { + button.setValue(match); + } + } catch (Exception e) { + log.debug( + "Could not apply default value for '{}': {}", + button.getPartialName(), + e.getMessage()); + } + } + + /** + * Builds a toggle's two-state /AP /N from drawing primitives, so no font resource is needed. + */ + private void applyToggleAppearance( + PDDocument document, PDAnnotationWidget widget, String onState, boolean isRadio) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + PDRectangle bbox = new PDRectangle(w, h); + + PDAppearanceDictionary appearance = new PDAppearanceDictionary(); + COSDictionary normalStates = new COSDictionary(); + normalStates.setItem( + COSName.getPDFName(OFF_STATE), + toggleStream(document, bbox, false, false).getCOSObject()); + normalStates.setItem( + COSName.getPDFName(onState), + toggleStream(document, bbox, true, isRadio).getCOSObject()); + appearance.getCOSObject().setItem(COSName.N, normalStates); + widget.setAppearance(appearance); + // Until a value selects it, the widget shows the Off appearance. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + } + + private PDAppearanceStream toggleStream( + PDDocument document, PDRectangle bbox, boolean on, boolean isRadio) throws IOException { + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(bbox); + stream.setResources(new PDResources()); + + float w = bbox.getWidth(); + float h = bbox.getHeight(); + float inset = Math.min(w, h) * 0.1f; + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setStrokingColor(0f, 0f, 0f); + content.setNonStrokingColor(0f, 0f, 0f); + content.setLineWidth(Math.max(0.5f, Math.min(w, h) * 0.06f)); + if (isRadio) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 2 - inset); + content.stroke(); + if (on) { + drawCircle(content, w / 2, h / 2, Math.min(w, h) / 4 - inset / 2); + content.fill(); + } + } else { + content.addRect(inset, inset, w - 2 * inset, h - 2 * inset); + content.stroke(); + if (on) { + content.moveTo(w * 0.25f, h * 0.5f); + content.lineTo(w * 0.45f, h * 0.28f); + content.lineTo(w * 0.78f, h * 0.72f); + content.stroke(); + } + } + } + return stream; + } + + /** + * Draws a push button's single {@code /AP /N} stream from its {@code /MK} characteristics. + * PDFBox never synthesizes one, so without this a push button has no appearance at all. + */ + private void applyPushButtonAppearance(PDDocument document, PDAnnotationWidget widget) + throws IOException { + PDRectangle rect = widget.getRectangle(); + if (rect == null || rect.getWidth() <= 0 || rect.getHeight() <= 0) { + return; + } + float w = rect.getWidth(); + float h = rect.getHeight(); + + PDAppearanceStream stream = new PDAppearanceStream(document); + stream.setBBox(new PDRectangle(w, h)); + stream.setResources(new PDResources()); + + PDAppearanceCharacteristicsDictionary mk = widget.getAppearanceCharacteristics(); + String caption = mk != null ? mk.getNormalCaption() : null; + // Honour the authored /MK colours; a hardcoded grey would restyle an existing button. + float[] background = mkColour(mk == null ? null : mk.getBackground(), 0.85f); + float[] border = mkColour(mk == null ? null : mk.getBorderColour(), 0f); + PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + float fontSize = Math.min(12f, h * 0.6f); + + try (PDPageContentStream content = + new PDPageContentStream( + document, stream, stream.getStream().createOutputStream())) { + content.setNonStrokingColor(background[0], background[1], background[2]); + content.addRect(0, 0, w, h); + content.fill(); + content.setStrokingColor(border[0], border[1], border[2]); + content.setLineWidth(1f); + content.addRect(0.5f, 0.5f, w - 1f, h - 1f); + content.stroke(); + if (caption != null && !caption.isBlank()) { + try { + float textWidth = font.getStringWidth(caption) / 1000f * fontSize; + content.beginText(); + content.setFont(font, fontSize); + content.setNonStrokingColor(0f, 0f, 0f); + content.newLineAtOffset( + Math.max(2f, (w - textWidth) / 2f), + (h - fontSize) / 2f + fontSize * 0.2f); + content.showText(caption); + content.endText(); + } catch (Exception e) { + // Unencodable caption: keep the frame, drop the text. + log.debug("Could not draw button caption '{}': {}", caption, e.getMessage()); + } + } + } + + // Reuse the existing dictionary so an authored /D or /R is not collateral damage. + PDAppearanceDictionary appearance = widget.getAppearance(); + if (appearance == null) { + appearance = new PDAppearanceDictionary(); + widget.setAppearance(appearance); + } + appearance.setNormalAppearance(stream); + // A push button has no value, so no /AS. + widget.getCOSObject().removeItem(COSName.AS); + } + + /** A /MK colour array as RGB, falling back to a grey level when absent or unsupported. */ + private float[] mkColour(PDColor colour, float fallback) { + float[] rgb = {fallback, fallback, fallback}; + if (colour == null) { + return rgb; + } + float[] components; + try { + components = colour.getComponents(); + } catch (Exception e) { + log.debug("Unreadable /MK colour: {}", e.getMessage()); + return rgb; + } + if (components.length == 3) { + rgb = components.clone(); + } else if (components.length == 1) { + rgb = new float[] {components[0], components[0], components[0]}; + } else if (components.length == 4) { + // CMYK to RGB, good enough for button chrome. + float k = components[3]; + rgb = + new float[] { + (1 - components[0]) * (1 - k), + (1 - components[1]) * (1 - k), + (1 - components[2]) * (1 - k) + }; + } + // PDPageContentStream rejects anything outside 0..1, and a throw here loses the appearance. + for (int i = 0; i < rgb.length; i++) { + rgb[i] = Math.min(1f, Math.max(0f, rgb[i])); + } + return rgb; + } + + /** A circle from four Bezier arcs; PDF has no primitive for one. */ + private void drawCircle(PDPageContentStream content, float cx, float cy, float r) + throws IOException { + if (r <= 0) { + return; + } + float k = r * 0.5523f; + content.moveTo(cx - r, cy); + content.curveTo(cx - r, cy + k, cx - k, cy + r, cx, cy + r); + content.curveTo(cx + k, cy + r, cx + r, cy + k, cx + r, cy); + content.curveTo(cx + r, cy - k, cx + k, cy - r, cx, cy - r); + content.curveTo(cx - k, cy - r, cx - r, cy - k, cx - r, cy); + content.closePath(); + } + + /** + * One widget per option stacked below {@code baseRect}, each keyed by its sanitized export + * value so the group behaves as a single selectable field. + */ + /** + * Per-option widget rects laid out INSIDE the drawn box, which is the group's total extent. + * Stacking outside it made a three-option group three times taller than what was drawn. + */ + public static List radioOptionRects( + PDRectangle box, int count, Float gapOverride, Float sizeOverride) { + List rects = new ArrayList<>(); + int n = Math.max(1, count); + float h = box.getHeight(); + float slot = h / n; + + float size; + if (sizeOverride != null && sizeOverride > 0f) { + size = sizeOverride; + } else if (gapOverride != null && gapOverride >= 0f) { + size = (h - (n - 1) * gapOverride) / n; + } else { + // A quarter of each slot is breathing room, so the stack fills the drawn height. + size = slot * 0.75f; + } + // Square keeps the circle round; a wide box becomes a left-aligned column. + size = Math.max(1f, Math.min(size, box.getWidth())); + + float gap; + if (gapOverride != null && gapOverride >= 0f) { + gap = gapOverride; + } else { + gap = n > 1 ? Math.max(0f, (h - n * size) / (n - 1)) : 0f; + } + + float top = box.getLowerLeftY() + h; + for (int i = 0; i < n; i++) { + float y = top - (i + 1) * size - i * gap; + rects.add(new PDRectangle(box.getLowerLeftX(), y, size, size)); + } + return rects; + } + + private void createRadioField( + PDAcroForm acroForm, + PDPage page, + PDRectangle baseRect, + String name, + NewFormFieldDefinition definition, + List options) + throws IOException { + + List values = (options == null || options.isEmpty()) ? List.of("1", "2") : options; + + PDRadioButton radio = new PDRadioButton(acroForm); + radio.setPartialName(name); + if (definition.label() != null && !definition.label().isBlank()) { + try { + radio.setAlternateFieldName(definition.label()); + } catch (Exception ignore) { + // alternate name is best-effort + } + } + radio.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + radio.setReadOnly(true); + } + + List optionRects = + radioOptionRects( + baseRect, values.size(), definition.optionGap(), definition.optionSize()); + + List widgets = new ArrayList<>(); + List exportValues = new ArrayList<>(); + Set usedStates = new HashSet<>(); + for (int i = 0; i < values.size(); i++) { + String onState = sanitizeOnState(values.get(i), i, usedStates); + exportValues.add(onState); + + PDRectangle rect = optionRects.get(i); + + PDAnnotationWidget widget = new PDAnnotationWidget(); + widget.setRectangle(rect); + widget.setPage(page); + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + widget.setParent(radio); + // The widget's appearance state is "Off" until the group value selects it. + widget.getCOSObject().setName(COSName.AS, OFF_STATE); + widgets.add(widget); + + List annotations = page.getAnnotations(); + if (annotations == null) { + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + annotations.add(widget); + } + + radio.setWidgets(widgets); + try { + radio.setExportValues(exportValues); + } catch (Exception e) { + log.debug("Unable to set radio export values for '{}': {}", name, e.getMessage()); + } + + String defaultValue = definition.defaultValue(); + if (defaultValue != null + && !defaultValue.isBlank() + && exportValues.contains(defaultValue)) { + try { + radio.setValue(defaultValue); + } catch (Exception e) { + log.debug("Unable to set radio default '{}': {}", defaultValue, e.getMessage()); + } + } + + acroForm.getFields().add(radio); + } + + /** Builds a unique, PDF-name-safe "on" state for a radio widget. */ + private String sanitizeOnState(String raw, int index, Set used) { + String base = + Optional.ofNullable(raw) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(FormUtils::sanitizePdfName) + .orElse("Option" + (index + 1)); + if (OFF_STATE.equalsIgnoreCase(base)) { + base = "Option" + (index + 1); + } + String candidate = base; + int suffix = 1; + while (!used.add(candidate)) { + candidate = base + "_" + suffix++; + } + return candidate; + } + + /** Reduces a label to characters that are safe inside a PDF name. */ + private static String sanitizePdfName(String raw) { + return raw == null ? "" : raw.trim().replaceAll("[^A-Za-z0-9_-]", "_"); + } + + /** Modify, then delete, then add, so generated names dedupe against the surviving set. */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes) + throws IOException { + applyFieldEdits(document, adds, modifies, deletes, null); + } + + /** + * As above, but records every operation that could not be applied into {@code skipped} so the + * caller can report "3 of 4" instead of a bare success. + */ + public void applyFieldEdits( + PDDocument document, + List adds, + List modifies, + List deletes, + List skipped) + throws IOException { + if (document == null) return; + if (modifies != null && !modifies.isEmpty()) { + modifyFormFields(document, modifies, skipped); + } + if (deletes != null && !deletes.isEmpty()) { + deleteFormFields(document, deletes, skipped); + } + if (adds != null && !adds.isEmpty()) { + addNewFields(document, adds, skipped); + } + } + + /** Adds an entry to a skip list that may be absent, so call sites stay one-liners. */ + private void recordSkip( + List skipped, String operation, String target, String reason) { + if (skipped != null) { + skipped.add(new SkippedFieldEdit(operation, target, reason)); + } + } + public void deleteFormFields(PDDocument document, List fieldNames) { + deleteFormFields(document, fieldNames, null); + } + + public void deleteFormFields( + PDDocument document, List fieldNames, List skipped) { if (document == null || fieldNames == null || fieldNames.isEmpty()) return; PDAcroForm acroForm = getAcroFormSafely(document); if (acroForm == null) { log.warn("Cannot delete fields because the document has no AcroForm"); + for (String name : fieldNames) { + recordSkip(skipped, "delete", name, "the document has no form to delete from"); + } return; } @@ -1847,6 +3053,7 @@ public class FormUtils { PDField field = locateField(acroForm, name.trim()); if (field == null) { log.warn("No matching field '{}' found for deletion", name); + recordSkip(skipped, "delete", name, "no field with that name exists"); continue; } @@ -2138,17 +3345,122 @@ public class FormUtils { return Collections.emptySet(); } Set existing = new HashSet<>(); + // Group (non-terminal) names occupy the namespace too, so a new field must not be + // allowed to take one; omitting them hides a whole class of collision. for (PDField field : acroForm.getFieldTree()) { - if (field instanceof PDTerminalField) { - String fqn = field.getFullyQualifiedName(); - if (fqn != null && !fqn.isEmpty()) { - existing.add(fqn); - } + String fqn = field.getFullyQualifiedName(); + if (fqn != null && !fqn.isEmpty()) { + existing.add(fqn); } } return existing; } + /** A text field's /MaxLen, or null when unset so the editor shows an empty box. */ + private Integer extractMaxLength(PDField field) { + if (field instanceof PDTextField textField) { + int maxLen = textField.getMaxLen(); + return maxLen > 0 ? maxLen : null; + } + return null; + } + + /** Reads a push button's action back into the same spec string the editor sends. */ + private String extractButtonAction(PDField field) { + if (!(field instanceof PDPushButton)) { + return null; + } + for (PDAnnotationWidget widget : field.getWidgets()) { + COSBase raw = widget.getCOSObject().getDictionaryObject(COSName.A); + if (!(raw instanceof COSDictionary action)) { + continue; + } + String subtype = action.getNameAsString(COSName.S); + if ("ResetForm".equals(subtype)) { + return "reset"; + } + if ("Named".equals(subtype)) { + return "Print".equalsIgnoreCase(action.getNameAsString(COSName.N)) ? "print" : null; + } + if ("URI".equals(subtype)) { + return "uri:" + Optional.ofNullable(action.getString(COSName.URI)).orElse(""); + } + if ("SubmitForm".equals(subtype)) { + return "submit:" + Optional.ofNullable(action.getString(COSName.F)).orElse(""); + } + } + return null; + } + + /** The parent prefix of a qualified name, including the trailing dot, or "" if top level. */ + private String parentPrefix(String qualifiedName) { + int dot = qualifiedName == null ? -1 : qualifiedName.lastIndexOf('.'); + return dot < 0 ? "" : qualifiedName.substring(0, dot + 1); + } + + /** + * The partial name a rename should set. Only a new name under the target's own parent may be + * qualified; anything else is used verbatim so it cannot silently re-parent the field. + */ + private String leafName(String targetName, String newName) { + String prefix = parentPrefix(targetName); + return !prefix.isEmpty() && newName.startsWith(prefix) + ? newName.substring(prefix.length()) + : newName; + } + + /** + * Why the rename is impossible, or null. An unchanged name is not a rename, so a field nested + * under a parent is not rejected for the period in its qualified name. + */ + public String renameProblem(String targetName, String newName) { + if (newName == null || newName.equals(targetName)) { + return null; + } + // A nested field's box shows "Parent.Child", so renaming the leaf under the same + // parent is legitimate; only the leaf has to be a storable partial name. + String trimmed = newName.trim(); + String leaf = leafName(targetName, trimmed); + if (!trimmed.isEmpty() && leaf.isBlank()) { + return "Field name '" + newName + "' has no name after the parent prefix."; + } + return invalidFieldNameReason(leaf); + } + + /** + * Why {@code name} is unusable as a field name, or null when it is fine. AcroForm reserves the + * period as the parent/child separator, so PDFBox rejects it outright in a partial name. + */ + public String invalidFieldNameReason(String name) { + if (name == null || name.isBlank()) { + return null; + } + if (name.chars().anyMatch(Character::isISOControl)) { + // A line break in a name would also forge a second line in every log it reaches. + return "Field name cannot contain line breaks or control characters."; + } + if (name.indexOf('.') >= 0) { + return "Field name '" + + sanitizeForLog(name) + + "' cannot contain a period. PDF forms use '.' to separate a parent field" + + " from its children."; + } + return null; + } + + /** + * A caller-supplied string made safe to log. Without this a name containing CR/LF writes an + * extra, attacker-chosen line into the log file (CWE-117). + */ + public static String sanitizeForLog(String value) { + if (value == null) { + return null; + } + StringBuilder out = new StringBuilder(value.length()); + value.chars().forEach(c -> out.append(Character.isISOControl(c) ? ' ' : (char) c)); + return out.toString(); + } + private PDField locateField(PDAcroForm acroForm, String name) { if (acroForm == null || name == null) { return null; @@ -2185,20 +3497,26 @@ public class FormUtils { } private String generateUniqueFieldName(String baseName, Set existingNames) { - String sanitized = + return generateUniqueFieldName(baseName, existingNames, ""); + } + + /** + * A partial name no sibling already uses. {@code qualifiedPrefix} is prepended only for the + * collision check, because {@code existingNames} holds fully qualified names. + */ + private String generateUniqueFieldName( + String baseName, Set existingNames, String qualifiedPrefix) { + // Trimmed, not sanitized: callers must reject bad names first via invalidFieldNameReason. + String trimmed = Optional.ofNullable(baseName) .map(String::trim) .filter(s -> !s.isEmpty()) .orElse("field"); - StringBuilder candidateBuilder = new StringBuilder(sanitized); - String candidate = candidateBuilder.toString(); + String candidate = trimmed; int counter = 1; - - while (existingNames.contains(candidate)) { - candidateBuilder.setLength(0); - candidateBuilder.append(sanitized).append("_").append(counter); - candidate = candidateBuilder.toString(); + while (existingNames.contains(qualifiedPrefix + candidate)) { + candidate = trimmed + "_" + counter; counter++; } @@ -2235,9 +3553,29 @@ public class FormUtils { } } field.setRequired(Boolean.TRUE.equals(definition.required())); + if (Boolean.TRUE.equals(definition.readOnly())) { + field.setReadOnly(true); + } - PDAnnotationWidget widget = - existingWidget != null ? existingWidget : new PDAnnotationWidget(); + // A terminal field with no /Kids shares its dictionary with one merged widget. + // A separately built widget is not linked via /Kids, so its /Rect is lost on save. + boolean reuseFieldDict; + PDAnnotationWidget widget; + if (existingWidget != null) { + widget = existingWidget; + reuseFieldDict = false; + } else { + List current = field.getWidgets(); + if (current != null && !current.isEmpty()) { + widget = current.get(0); + } else { + widget = new PDAnnotationWidget(); + } + reuseFieldDict = widget.getCOSObject() == field.getCOSObject(); + // Make sure the shared dictionary is recognised as a widget annotation. + widget.getCOSObject().setItem(COSName.TYPE, COSName.getPDFName("Annot")); + widget.getCOSObject().setItem(COSName.SUBTYPE, COSName.getPDFName("Widget")); + } // Ensure rectangle is valid and set before any appearance-related operations // please note removal of this might cause **subtle** issues @@ -2250,10 +3588,10 @@ public class FormUtils { } widget.setRectangle(validRectangle); widget.setPage(page); - - if (existingWidget == null) { - widget.setPrinted(true); - } + // Explicitly set the /P entry so the widget keeps a valid page reference + // after save/reload (some viewers rely on it to resolve the widget page). + widget.getCOSObject().setItem(COSName.P, page.getCOSObject()); + widget.setPrinted(true); if (definition.tooltip() != null && !definition.tooltip().isBlank()) { widget.getCOSObject().setString(COSName.TU, definition.tooltip()); @@ -2265,13 +3603,25 @@ public class FormUtils { } } - field.getWidgets().add(widget); - widget.setParent(field); + // Only link a SEPARATE widget into the field; the merged widget IS the + // field dictionary and is already its own widget. + if (!reuseFieldDict) { + List widgets = new ArrayList<>(field.getWidgets()); + if (!widgets.contains(widget)) { + widgets.add(widget); + field.setWidgets(widgets); + } + widget.setParent(field); + } List annotations = page.getAnnotations(); if (annotations == null) { - page.getAnnotations().add(widget); - } else if (!annotations.contains(widget)) { + // page.getAnnotations() can return null; calling it again and adding + // would NPE. Initialise the list and attach it to the page first. + annotations = new ArrayList<>(); + page.setAnnotations(annotations); + } + if (!annotations.contains(widget)) { annotations.add(widget); } acroForm.getFields().add(field); @@ -2399,7 +3749,60 @@ public class FormUtils { Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; derived from the drawn box when null. */ + Float optionGap, + /** Radio option size in points; derived from the drawn box when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to derived. */ + public NewFormFieldDefinition( + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } @JsonInclude(JsonInclude.Include.NON_NULL) public record ModifyFormFieldDefinition( @@ -2407,11 +3810,120 @@ public class FormUtils { String name, String label, String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, Boolean required, Boolean multiSelect, List options, String defaultValue, - String tooltip) {} + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction, + /** Gap between radio options in points; leaves the existing layout alone when null. */ + Float optionGap, + /** Radio option size in points; leaves the existing layout alone when null. */ + Float optionSize) { + + /** The shape before option layout was tunable; both extras default to unchanged. */ + public ModifyFormFieldDefinition( + String targetName, + String name, + String label, + String type, + Integer pageIndex, + Float x, + Float y, + Float width, + Float height, + Boolean required, + Boolean multiSelect, + List options, + String defaultValue, + String tooltip, + Float fontSize, + Boolean readOnly, + Boolean multiline, + Integer maxLength, + String buttonAction) { + this( + targetName, + name, + label, + type, + pageIndex, + x, + y, + width, + height, + required, + multiSelect, + options, + defaultValue, + tooltip, + fontSize, + readOnly, + multiline, + maxLength, + buttonAction, + null, + null); + } + } + + /** A mixed batch of field edits applied in one request via {@link #applyFieldEdits}. */ + @JsonInclude(JsonInclude.Include.NON_NULL) + public record FieldEditBatch( + List add, + List modify, + List delete) {} + + /** + * One requested edit the document could not take, so the caller can report "3 of 4" rather than + * a bare success. {@code operation} is "add", "modify" or "delete". + */ + @JsonInclude(JsonInclude.Include.NON_NULL) + /** + * Turns a library failure into something a person can act on. Raw messages like "/DR is a + * required entry" name PDF internals the user has never heard of. + */ + public static String readableFailure(Throwable failure) { + String raw = failure == null ? null : failure.getMessage(); + if (raw == null || raw.isBlank()) { + return "this PDF would not accept the change"; + } + String lower = raw.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("/dr") || lower.contains("default resources")) { + return "this PDF's form has no font settings, so the field could not be styled"; + } + if (lower.contains("font") && lower.contains("not")) { + return "the font this field asks for is not embedded in the PDF"; + } + if (lower.contains("encrypt") || lower.contains("password")) { + return "the PDF is protected, so its form cannot be changed"; + } + if (lower.contains("read-only") || lower.contains("readonly")) { + return "the field is read-only in this PDF"; + } + // Anything unrecognised stays vague rather than leaking internals at the user. + log.debug("Unmapped form edit failure: {}", raw); + return "this PDF would not accept the change"; + } + + /** Skip reasons travel in a response header, so an echoed value cannot be unbounded. */ + public static String abbreviate(String value, int max) { + if (value == null || value.length() <= max) { + return value; + } + return value.substring(0, max) + "..."; + } + + public record SkippedFieldEdit(String operation, String target, String reason) {} @JsonInclude(JsonInclude.Include.NON_NULL) public record FormFieldInfo( @@ -2433,19 +3945,25 @@ public class FormUtils { static final class FieldCoordinateComparator implements Comparator { private static int firstWidgetPageIndex(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getPageIndex() : -1; } private static float firstWidgetY(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getY() : 0; } private static float firstWidgetX(FormFieldWithCoordinates f) { - return (f.getWidgets() != null && !f.getWidgets().isEmpty()) + return (f.getWidgets() != null + && !f.getWidgets().isEmpty() + && f.getWidgets().getFirst() != null) ? f.getWidgets().getFirst().getX() : 0; } diff --git a/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java new file mode 100644 index 0000000000..b5312576e4 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/ChoiceOptionRoundTripTest.java @@ -0,0 +1,116 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSObject; +import org.apache.pdfbox.cos.COSObjectKey; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Pins how a choice field's options survive a save, which real forms rely on. */ +class ChoiceOptionRoundTripTest { + + private static PDComboBox combo(PDDocument document, List options) throws IOException { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDComboBox field = new PDComboBox(form); + field.setPartialName("state"); + field.setOptions(options); + form.getFields().add(field); + return field; + } + + @Test + @DisplayName("a whitespace-only option survives a load, save and reload") + void whitespaceOptionSurvivesRoundTrip() throws IOException { + List options = List.of(" ", "Alabama", "Alaska"); + + byte[] first; + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + combo(document, options); + document.save(out); + first = out.toByteArray(); + } + // The real path edits a document loaded from bytes, not one built in memory. + byte[] saved; + try (PDDocument loaded = Loader.loadPDF(first); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + loaded.save(out); + saved = out.toByteArray(); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDComboBox reread = + (PDComboBox) reloaded.getDocumentCatalog().getAcroForm(null).getField("state"); + assertEquals( + options, + reread.getOptionsExportValues(), + "an option must not vanish because the writer made it indirect"); + } + } + + @Test + @DisplayName("an option stored as an indirect reference is still reported") + void indirectOptionIsStillReported() throws IOException { + try (PDDocument document = new PDDocument()) { + PDComboBox field = combo(document, List.of(" ", "Alabama")); + + // Real forms reference option strings indirectly; the reader must follow the reference. + COSArray options = new COSArray(); + options.add(new COSObject(new COSString(" "), new COSObjectKey(629, 0))); + options.add(new COSString("Alabama")); + field.getCOSObject().setItem(COSName.OPT, options); + + // Every read path runs this repair first, which is where the reference is followed. + FormUtils.repairMissingWidgetPageReferences(document); + + assertEquals( + List.of(" ", "Alabama"), + field.getOptionsExportValues(), + "an indirectly stored option must not be dropped"); + } + } + + @Test + @DisplayName("a signature field reports no value rather than a JVM identity hash") + void signatureValueIsNotAnIdentityHash() throws IOException { + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + PDSignatureField signature = new PDSignatureField(form); + signature.setPartialName("approval"); + // Only a field that actually holds a signature hits getValueAsString's toString(). + signature.setValue(new PDSignature()); + form.getFields().add(signature); + + List fields = FormUtils.extractFormFields(document); + + FormUtils.FormFieldInfo field = + fields.stream() + .filter(f -> "approval".equals(f.name())) + .findFirst() + .orElseThrow(); + // An identity hash differs per load, so the same document would describe itself twice. + assertNull(field.value(), "a signature has no text value"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java new file mode 100644 index 0000000000..4c28952ff0 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/DeepFieldTreeTest.java @@ -0,0 +1,62 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A hostile or corrupt form must fail as a rejected request, never as a crashed thread. */ +class DeepFieldTreeTest { + + private static byte[] chainOfKids(int depth) throws IOException { + try (PDDocument document = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(document); + document.getDocumentCatalog().setAcroForm(form); + + COSDictionary root = new COSDictionary(); + root.setString(COSName.T, "n0"); + COSDictionary cursor = root; + for (int i = 1; i < depth; i++) { + COSDictionary kid = new COSDictionary(); + kid.setString(COSName.T, "n" + i); + kid.setItem(COSName.PARENT, cursor); + COSArray kids = new COSArray(); + kids.add(kid); + cursor.setItem(COSName.KIDS, kids); + cursor = kid; + } + cursor.setItem(COSName.FT, COSName.getPDFName("Tx")); + + COSArray fields = new COSArray(); + fields.add(root); + form.getCOSObject().setItem(COSName.FIELDS, fields); + document.save(out); + return out.toByteArray(); + } + } + + @Test + @DisplayName("a deeply nested field tree extracts without overflowing the stack") + void deepKidsChainDoesNotOverflow() throws IOException { + // 2000 is as deep as PDFBox's own writer can build here; beyond that the overflow is in + // the writer, not in extraction, so it is not something a read endpoint would hit. + byte[] pdf = chainOfKids(2000); + + try (PDDocument document = Loader.loadPDF(pdf)) { + assertDoesNotThrow(() -> FormUtils.extractFormFieldsWithCoordinates(document)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java new file mode 100644 index 0000000000..1f213c15ba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormEditSafetyTest.java @@ -0,0 +1,201 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.FormFieldWithCoordinates; + +/** An edit that cannot be honoured must be refused and reported, never silently reshaped. */ +class FormEditSafetyTest { + + private static PDDocument formWith(String name, String type) throws IOException { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.A4)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + 50f, + 700f, + 200f, + 20f, + null, + null, + type.equals("radio") ? List.of("a", "b") : null, + null, + null, + null, + null, + null, + null, + null))); + return document; + } + + private static FormUtils.ModifyFormFieldDefinition modify( + String target, String type, Float width, Float height) { + // Order: targetName, name, label, type, pageIndex, x, y, width, height, then the rest. + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, type, null, null, null, width, height, null, null, null, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a type that cannot be rebuilt is refused instead of becoming a text field") + void unrebuildableTypeIsRefused() throws IOException { + try (PDDocument document = formWith("choice", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("choice", "radio", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("choice"); + assertFalse(skipped.isEmpty(), "the refusal must be reported to the caller"); + assertFalse( + field instanceof PDRadioButton, + "it could not become a radio, so it must not claim to be one"); + assertEquals( + "text", + FormUtils.extractFormFields(document).getFirst().type(), + "the original field must survive untouched rather than be retyped"); + } + } + + @Test + @DisplayName("a field rebuilt as a checkbox gets an appearance so it can be ticked") + void rebuiltCheckboxIsUsable() throws IOException { + try (PDDocument document = formWith("agree", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("agree", "checkbox", null, null)), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("agree"); + assertTrue(field instanceof PDCheckBox, "the rebuild should have produced a checkbox"); + assertNotNull( + field.getWidgets().getFirst().getAppearance(), + "without an appearance the checkbox renders blank and cannot be ticked"); + } + } + + @Test + @DisplayName("a size of zero or infinity is refused rather than written into the page") + void unusableSizeIsRefused() throws IOException { + for (Float bad : new Float[] {0f, -5f, Float.POSITIVE_INFINITY, Float.NaN}) { + try (PDDocument document = formWith("box", "text")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(modify("box", null, bad, 20f)), skipped); + + PDRectangle rect = + document.getDocumentCatalog() + .getAcroForm(null) + .getField("box") + .getWidgets() + .getFirst() + .getRectangle(); + assertFalse(skipped.isEmpty(), "a refused resize must be reported: width " + bad); + assertEquals( + 200f, + rect.getWidth(), + 0.01f, + "the original size must survive: width " + bad); + } + } + } + + @Test + @DisplayName("a widget off the page still reports its geometry instead of dropping the field") + void offPageWidgetKeepsItsGeometry() throws IOException { + try (PDDocument document = formWith("stray", "text")) { + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("stray"); + // Above the page top: legal PDF, and the user needs the coordinates to drag it back. + field.getWidgets().getFirst().setRectangle(new PDRectangle(50f, 2000f, 200f, 20f)); + + List fields = + FormUtils.extractFormFieldsWithCoordinates(document); + + FormFieldWithCoordinates stray = + fields.stream() + .filter(f -> "stray".equals(f.getName())) + .findFirst() + .orElseThrow(); + assertNotNull(stray.getWidgets(), "the field must keep its widget list"); + assertFalse(stray.getWidgets().isEmpty(), "the off-page widget must still be reported"); + assertNotNull(stray.getWidgets().getFirst(), "a null entry would crash the overlay"); + } + } + + private static FormUtils.ModifyFormFieldDefinition withValue(String target, String value) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, null, value, + null, null, null, null, null, null); + } + + private static FormUtils.ModifyFormFieldDefinition withOptions( + String target, List options) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, null, null, null, null, null, null, null, options, null, + null, null, null, null, null, null); + } + + @Test + @DisplayName("a value a radio group cannot hold does not destroy the group") + void badRadioValueLeavesTheGroupIntact() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withValue("plan", "not-an-option")), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + assertTrue( + field instanceof PDRadioButton, + "a rejected value must not turn the group into another kind of field"); + assertEquals( + 2, + field.getWidgets().size(), + "the group's options must survive a rejected value"); + assertFalse(skipped.isEmpty(), "the caller must be told the value was not applied"); + } + } + + @Test + @DisplayName("editing a radio group's options is either applied or reported, never ignored") + void radioOptionEditIsNotSilentlyDropped() throws IOException { + try (PDDocument document = formWith("plan", "radio")) { + List skipped = new ArrayList<>(); + + FormUtils.modifyFormFields( + document, List.of(withOptions("plan", List.of("a", "b", "c"))), skipped); + + PDField field = document.getDocumentCatalog().getAcroForm(null).getField("plan"); + boolean applied = field.getWidgets().size() == 3; + assertTrue( + applied || !skipped.isEmpty(), + "a change the UI shows as saved must either happen or be reported as skipped"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java new file mode 100644 index 0000000000..c1b0c806e1 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldNameSafetyTest.java @@ -0,0 +1,61 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** + * A field name is caller-supplied and reaches several loggers. A line break in one would forge a + * second log line (CWE-117), so names carrying control characters are refused outright. + */ +class FormFieldNameSafetyTest { + + @Test + void aNameWithCrLfIsRefused() { + String forged = "evil\r\n2026-01-01 00:00:00 ERROR admin login from 1.2.3.4"; + String reason = FormUtils.invalidFieldNameReason(forged); + assertNotNull(reason, "a name containing CR/LF must be refused"); + assertFalse(reason.contains("\n"), "the refusal itself must not carry a line break"); + assertFalse(reason.contains("\r"), "the refusal itself must not carry a carriage return"); + } + + @Test + void otherControlCharactersAreRefusedToo() { + assertNotNull(FormUtils.invalidFieldNameReason("tab\there")); + assertNotNull(FormUtils.invalidFieldNameReason("null\u0000byte")); + } + + @Test + void ordinaryNamesStillPass() { + assertNull(FormUtils.invalidFieldNameReason("Full Name")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason("Mr Smith")); + } + + @Test + void thePeriodRefusalDoesNotEchoControlCharacters() { + // Both problems at once: the period branch must not leak the raw name into a log line. + String reason = FormUtils.invalidFieldNameReason("Customer.Name\r\nFORGED"); + assertNotNull(reason); + assertFalse(reason.contains("\r") || reason.contains("\n"), "no raw line break: " + reason); + } + + @Test + void sanitizeForLogFlattensControlCharacters() { + assertEquals("a b", FormUtils.sanitizeForLog("a\nb")); + assertEquals("a b", FormUtils.sanitizeForLog("a\rb")); + assertEquals("plain", FormUtils.sanitizeForLog("plain")); + assertNull(FormUtils.sanitizeForLog(null)); + } + + @Test + void aPeriodIsStillRefusedWithTheOffendingCharacterNamed() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason); + assertTrue(reason.contains("period"), "the message should name the problem: " + reason); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java index 771ce89659..6924764a65 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormFieldTypeSupportTest.java @@ -130,13 +130,15 @@ class FormFieldTypeSupportTest { } @Test - void doesNotSupportsDefinitionCreation_signatureReturnsTrue() { - assertTrue(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_signatureReturnsFalse() { + // Signature placeholders are now creatable via the editor. + assertFalse(FormFieldTypeSupport.SIGNATURE.doesNotsupportsDefinitionCreation()); } @Test - void doesNotSupportsDefinitionCreation_buttonReturnsTrue() { - assertTrue(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); + void doesNotSupportsDefinitionCreation_buttonReturnsFalse() { + // Push buttons (with actions) are now creatable via the editor. + assertFalse(FormFieldTypeSupport.BUTTON.doesNotsupportsDefinitionCreation()); } @Test diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java new file mode 100644 index 0000000000..408380b790 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditRegressionTest.java @@ -0,0 +1,911 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Guards the form editor against silently destroying a field it edits. Assertions run after a + * save/reload cycle because only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditRegressionTest { + + private static PDAcroForm setupForm(PDDocument document) { + document.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + /** Moves a field to a rect; null width/height leave the size alone. */ + private static FormUtils.ModifyFormFieldDefinition moveTo( + String target, float x, float y, Float w, Float h) { + return new FormUtils.ModifyFormFieldDefinition( + target, null, null, null, 0, x, y, w, h, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + return field.getWidgets().get(0).getRectangle(); + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(Collectors.toSet()); + } + + @Test + void movingCheckboxKeepsItFillable() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 200f, 400f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("agree"); + assertTrue(field instanceof PDCheckBox, "'agree' should still be a checkbox"); + assertFalse( + ((PDCheckBox) field).getOnValue().isEmpty(), + "a moved checkbox must keep an on-state, or it can never be ticked again"); + assertTrue( + normalStateNames(field.getWidgets().get(0)).size() >= 2, + "both /AP /N states must survive a move"); + PDRectangle rect = firstWidgetRect(acroForm, "agree"); + assertEquals(200f, rect.getLowerLeftX(), 0.5f); + assertEquals(400f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void resizingCheckboxRebuildsAppearanceAtTheNewSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 28f, 28f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox checkBox = (PDCheckBox) acroForm.getField("agree"); + assertFalse( + checkBox.getOnValue().isEmpty(), "a resized checkbox must keep its on-state"); + PDAnnotationWidget widget = checkBox.getWidgets().get(0); + assertTrue(normalStateNames(widget).size() >= 2, "both /AP /N states must be rebuilt"); + PDRectangle bbox = + widget.getAppearance() + .getNormalAppearance() + .getSubDictionary() + .get(COSName.getPDFName(checkBox.getOnValue())) + .getBBox(); + assertEquals(28f, bbox.getWidth(), 0.5f, "the rebuilt /AP must match the new size"); + } + } + + /** applyToggleAppearance parks /AS on Off, so a resize must put the selection back. */ + @Test + void resizingCheckboxKeepsItChecked() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + ((PDCheckBox) form.getField("agree")).check(); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + ((PDCheckBox) acroForm.getField("agree")).isChecked(), + "a resize must not silently untick the box"); + } + } + + /** Only widgets.get(0) used to move, so a radio group lost every option but the first. */ + @Test + void movingRadioGroupMovesEveryOption() throws IOException { + byte[] saved; + float[] before = new float[6]; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 14, 14, List.of("A", "B", "C")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + assertEquals(3, widgets.size(), "the fixture needs three option widgets"); + for (int i = 0; i < 3; i++) { + before[i * 2] = widgets.get(i).getRectangle().getLowerLeftX(); + before[i * 2 + 1] = widgets.get(i).getRectangle().getLowerLeftY(); + } + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 670f, null, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertTrue(field instanceof PDRadioButton, "'choice' should still be a radio group"); + List widgets = field.getWidgets(); + assertEquals(3, widgets.size(), "no option may be left behind"); + float dx = 90f - before[0]; + float dy = 670f - before[1]; + for (int i = 0; i < 3; i++) { + PDRectangle rect = widgets.get(i).getRectangle(); + assertEquals( + before[i * 2] + dx, + rect.getLowerLeftX(), + 0.5f, + "option " + i + " should shift by the same delta"); + assertEquals(before[i * 2 + 1] + dy, rect.getLowerLeftY(), 0.5f); + } + } + } + + /** A signature's /AP is the signature, so it must never be dropped. */ + @Test + void movingSignatureKeepsItsAppearance() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("signature", "sig", 50, 700, 120, 40, null))); + FormUtils.modifyFormFields(document, List.of(moveTo("sig", 60f, 600f, 140f, 50f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "'sig' should still be a signature"); + assertEquals(60f, firstWidgetRect(acroForm, "sig").getLowerLeftX(), 0.5f); + } + } + + @Test + void invalidFieldNameReason_rejectsPeriodAndAllowsTheRest() { + String reason = FormUtils.invalidFieldNameReason("Customer.Name"); + assertNotNull(reason, "a period must be refused, not silently dropped"); + assertTrue(reason.contains("period"), "the message should name the offending character"); + assertNull(FormUtils.invalidFieldNameReason("Has Space")); + assertNull(FormUtils.invalidFieldNameReason("weird/[]{}")); + assertNull(FormUtils.invalidFieldNameReason(null)); + } + + /** Dropped operations used to log a warning and still report success. */ + @Test + void applyFieldEdits_reportsEveryDroppedOperation() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "present", 50, 700, 200, 20, null))); + + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "Bad.Name", 50, 600, 100, 20, null)), + List.of(moveTo("ghost", 10f, 10f, null, null)), + List.of("alsoGhost"), + skipped); + + assertEquals(3, skipped.size(), "each dropped operation should be reported"); + assertTrue(skipped.stream().anyMatch(s -> "add".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "modify".equals(s.operation()))); + assertTrue(skipped.stream().anyMatch(s -> "delete".equals(s.operation()))); + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("present"), + "the rest of the document must still be applied"); + } + } + + /** A clean batch must not report anything, or the UI would cry wolf on every save. */ + @Test + void applyFieldEdits_reportsNothingWhenEverythingApplies() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.applyFieldEdits( + document, + List.of(newField("text", "fine", 50, 700, 200, 20, null)), + List.of(), + List.of(), + skipped); + assertTrue(skipped.isEmpty(), "a fully applied batch reports no skips"); + } + } + + /** A drag must not normalise other options to the dragged widget's size. */ + @Test + void movingRadioGroupKeepsEachOptionsOwnSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of(newField("radio", "choice", 50, 700, 20, 20, List.of("A", "B")))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + List widgets = form.getField("choice").getWidgets(); + // Hand-authored groups legitimately have option boxes of differing size. + PDRectangle second = widgets.get(1).getRectangle(); + widgets.get(1) + .setRectangle( + new PDRectangle( + second.getLowerLeftX(), second.getLowerLeftY(), 40f, 40f)); + FormUtils.modifyFormFields(document, List.of(moveTo("choice", 90f, 700f, 20f, 20f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + List widgets = acroForm.getField("choice").getWidgets(); + assertEquals( + 40f, + widgets.get(1).getRectangle().getWidth(), + 0.5f, + "a pure drag must not shrink the other options"); + assertEquals(90f, widgets.get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** With no /AP and no /Opt the on-state must come from /V, not the invented "Yes". */ + @Test + void resizingCheckboxWithoutAppearanceKeepsItsExportValue() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("checkbox", "agree", 50, 700, 14, 14, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) form.getField("agree"); + // A NeedAppearances form exported by Word/LibreOffice looks exactly like this. + box.getWidgets().get(0).getCOSObject().removeItem(COSName.AP); + box.getCOSObject().setItem(COSName.V, COSName.getPDFName("On")); + FormUtils.modifyFormFields(document, List.of(moveTo("agree", 50f, 700f, 30f, 30f))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertEquals( + "On", + box.getOnValue(), + "the export value must survive; inventing 'Yes' would orphan /V"); + assertTrue(box.isChecked(), "the box was ticked and must stay ticked"); + } + } + + /** Renaming to the same qualified name is not a rename, so a nested field is not rejected. */ + @Test + void renameProblem_ignoresAnUnchangedQualifiedName() { + assertNull( + FormUtils.renameProblem("Customer.Name", "Customer.Name"), + "a field standing still must not be rejected for its parent's period"); + assertNull(FormUtils.renameProblem("plain", null)); + assertNotNull( + FormUtils.renameProblem("plain", "New.Name"), + "an actual rename introducing a period must still be refused"); + } + + /** A nested field whose name box was left at its qualified name must still be modified. */ + @Test + void modifyingNestedFieldKeepsWorkingWhenNameIsUntouched() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + // Re-parent it so its qualified name legitimately contains a period. + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Name", + null, + null, + 0, + 90f, + 600f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + assertTrue( + skipped.isEmpty(), "an untouched qualified name is not a rename: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("Customer.Name"); + assertNotNull(field, "the nested field must survive the edit"); + assertEquals(90f, field.getWidgets().get(0).getRectangle().getLowerLeftX(), 0.5f); + } + } + + /** Zero clears /MaxLen; null means unchanged, so it could never be removed otherwise. */ + @Test + void maxLengthZeroClearsTheCombSetting() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "code", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, 8, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + assertEquals(8, ((PDTextField) form.getField("code")).getMaxLen()); + + FormUtils.ModifyFormFieldDefinition clear = + new FormUtils.ModifyFormFieldDefinition( + "code", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, 0, null); + FormUtils.modifyFormFields(document, List.of(clear)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertEquals( + -1, + ((PDTextField) acroForm.getField("code")).getMaxLen(), + "/MaxLen should be gone, not merely zero"); + } + } + + /** An unrecognised button action must be reported rather than silently ignored. */ + @Test + void unknownButtonActionIsReported() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "an unusable action spec should be reported"); + assertTrue(skipped.get(0).reason().contains("launchTheMissiles")); + } + } + + /** Renaming a nested field must not re-parent it to the top level. */ + @Test + void renamingNestedFieldKeepsItUnderItsParent() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Phone", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(rename), skipped); + assertTrue( + skipped.isEmpty(), "a leaf rename under the same parent is legal: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Phone"), + "the field should still live under Customer, not at the top level"); + assertNull(acroForm.getField("Customer.Name"), "the old name should be gone"); + } + } + + /** One rejected action on a multi-widget button is one report, not one per widget. */ + @Test + void unknownButtonActionIsReportedOncePerField() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + PDAcroForm form = document.getDocumentCatalog().getAcroForm(null); + PDField button = form.getField("go"); + // Give it a second widget, as a button repeated on two pages would have. + PDAnnotationWidget extra = new PDAnnotationWidget(); + extra.setRectangle(new PDRectangle(50, 600, 100, 24)); + extra.getCOSObject().setItem(COSName.PARENT, button.getCOSObject()); + List widgets = new ArrayList<>(button.getWidgets()); + widgets.add(extra); + button.getCOSObject() + .setItem( + COSName.KIDS, + new org.apache.pdfbox.cos.COSArray() { + { + for (PDAnnotationWidget w : widgets) add(w.getCOSObject()); + } + }); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "launchTheMissiles"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(mod), skipped); + + assertEquals(1, skipped.size(), "one field, one report: " + skipped); + } + } + + /** A clamped page index still creates the field, so it is not a dropped edit. */ + @Test + void clampedPageIsNotReportedAsSkipped() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "late", null, "text", 9, 50f, 700f, 100f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertNotNull( + document.getDocumentCatalog().getAcroForm(null).getField("late"), + "the field is created on the clamped page"); + assertTrue(skipped.isEmpty(), "an applied edit must not appear as skipped: " + skipped); + } + } + + /** Recreation builds a top-level field, so it must refuse rather than re-parent. */ + @Test + void typeChangeOnNestedFieldIsRefusedNotSilentlyReparented() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("text", "Name", 50, 700, 200, 20, null))); + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName("Customer"); + PDField child = form.getField("Name"); + parent.setChildren(List.of(child)); + child.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + form.setFields(List.of(parent)); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(retype), skipped); + + assertEquals(1, skipped.size(), "the refusal must be reported: " + skipped); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull( + acroForm.getField("Customer.Name"), + "the original nested field must be left intact"); + assertNull(acroForm.getField("Name"), "nothing should be re-parented to the top level"); + } + } + + /** The editor emits "uri:" the moment that kind is picked, which must not fail the edit. */ + @Test + void incompleteUrlActionClearsRatherThanFailing() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition pickUri = + new FormUtils.ModifyFormFieldDefinition( + "go", null, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, "uri:"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(pickUri), skipped); + + assertTrue( + skipped.isEmpty(), + "choosing a URL action before typing the URL is not an error: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "an empty target must leave no action behind"); + } + } + + /** A real URL still writes a real action. */ + @Test + void completeUrlActionIsApplied() throws IOException { + try (PDDocument document = new PDDocument()) { + setupForm(document); + FormUtils.addNewFields( + document, List.of(newField("button", "go", 50, 700, 100, 24, null))); + + FormUtils.ModifyFormFieldDefinition setUri = + new FormUtils.ModifyFormFieldDefinition( + "go", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + "uri:https://example.com"); + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(setUri), skipped); + + assertTrue(skipped.isEmpty(), "a complete spec applies cleanly: " + skipped); + PDField button = document.getDocumentCatalog().getAcroForm(null).getField("go"); + assertNotNull( + button.getWidgets().get(0).getCOSObject().getDictionaryObject(COSName.A), + "the action should be written"); + } + } + + /** Builds a parent with the given terminal children already attached. */ + private static PDNonTerminalField nest( + PDDocument document, PDAcroForm form, String parentName, String... childNames) + throws IOException { + List defs = new ArrayList<>(); + for (int i = 0; i < childNames.length; i++) { + defs.add(newField("text", childNames[i], 50, 700 - i * 40, 200, 20, null)); + } + FormUtils.addNewFields(document, defs); + + PDNonTerminalField parent = new PDNonTerminalField(form); + parent.setPartialName(parentName); + List kids = new ArrayList<>(); + for (String child : childNames) { + PDField field = form.getField(child); + field.getCOSObject().setItem(COSName.PARENT, parent.getCOSObject()); + kids.add(field); + } + parent.setChildren(kids); + form.setFields(List.of(parent)); + return parent; + } + + /** A refused edit must not release the name the field still really has. */ + @Test + void refusedNestedEditDoesNotFreeItsNameForALaterEdit() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name", "Email"); + + // Edit 1 is refused (type change on a nested field). Edit 2 then asks for the + // name edit 1 still occupies, which must not be handed out. + FormUtils.ModifyFormFieldDefinition refused = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Name", + "Customer.Foo", + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.ModifyFormFieldDefinition rename = + new FormUtils.ModifyFormFieldDefinition( + "Customer.Email", + "Customer.Name", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + + List skipped = new ArrayList<>(); + FormUtils.modifyFormFields(document, List.of(refused, rename), skipped); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + if (f instanceof PDTerminalField) names.add(f.getFullyQualifiedName()); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "two fields must never share a qualified name: " + names); + assertTrue( + names.contains("Customer.Name"), "the refused field keeps its name: " + names); + } + } + + /** A group name occupies the namespace, so a new field must not be able to take it. */ + @Test + void groupNamesParticipateInCollisionChecks() throws IOException { + try (PDDocument document = new PDDocument()) { + PDAcroForm form = setupForm(document); + nest(document, form, "Customer", "Name"); + + FormUtils.addNewFields( + document, List.of(newField("text", "Customer", 50, 500, 100, 20, null))); + + List names = new ArrayList<>(); + for (PDField f : document.getDocumentCatalog().getAcroForm(null).getFieldTree()) { + String fqn = f.getFullyQualifiedName(); + if (fqn != null) names.add(fqn); + } + assertEquals( + names.size(), + new java.util.HashSet<>(names).size(), + "the new field must not take the group's name: " + names); + } + } + + /** "Customer." has no leaf, so it must be refused rather than become "Customer.field". */ + @Test + void renameToBareParentPrefixIsRefused() { + assertNotNull( + FormUtils.renameProblem("Customer.Name", "Customer."), + "a name with nothing after the parent prefix is not a rename"); + assertNull(FormUtils.renameProblem("Customer.Name", "Customer.Phone")); + } + + /** A type change must leave the field on its own page, not relocate it to the last one. */ + @Test + void typeChangeKeepsTheFieldOnItsPage() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm form = new PDAcroForm(document); + for (int i = 0; i < 5; i++) { + document.addPage(new PDPage(PDRectangle.A4)); + } + form.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(form); + + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "onPageTwo", + null, + "text", + 1, + 50f, + 700f, + 200f, + 20f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null))); + + FormUtils.ModifyFormFieldDefinition retype = + new FormUtils.ModifyFormFieldDefinition( + "onPageTwo", + null, + null, + "checkbox", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(retype)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("onPageTwo"); + assertNotNull(field, "the retyped field should exist"); + int page = -1; + for (int i = 0; i < reloaded.getNumberOfPages(); i++) { + for (var annot : reloaded.getPage(i).getAnnotations()) { + if (annot.getCOSObject() == field.getWidgets().get(0).getCOSObject()) page = i; + } + } + assertEquals( + 1, page, "a retyped field must stay on its own page, not move to the last"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java new file mode 100644 index 0000000000..2c606d481a --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditReportingTest.java @@ -0,0 +1,118 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.junit.jupiter.api.Test; + +/** An edit the backend cannot honour must be reported, not logged and reported as success. */ +class FormUtilsEditReportingTest { + + private static FormUtils.NewFormFieldDefinition field(String type, String name) { + return new FormUtils.NewFormFieldDefinition( + name, name, type, 0, 60f, 700f, 120f, 20f, null, null, null, null, null, null, null, + null, null, null); + } + + private static PDDocument blank() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + @Test + void anUncreatableTypeIsReportedRatherThanSilentlyMadeText() throws IOException { + List skipped = new ArrayList<>(); + try (PDDocument document = blank()) { + FormUtils.addNewFields(document, List.of(field("nonsense", "mystery")), skipped); + PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getFields().isEmpty(), + "an unsupported type must not quietly become a text field"); + } + assertEquals(1, skipped.size(), "the caller must be told: " + skipped); + assertTrue(skipped.get(0).reason().contains("nonsense"), skipped.get(0).reason()); + } + + @Test + void aLyingPageCountIsSurvivable() throws IOException { + // /Count overstates the tree, so getNumberOfPages() passes the guard but getPage throws. + byte[] broken = + ("%PDF-1.4\n" + + "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n" + + "2 0 obj << /Type /Pages /Count 1 /Kids [] >> endobj\n" + + "trailer << /Root 1 0 R >>\n") + .getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); + List skipped = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(broken)) { + // Must not throw; the field is reported as skipped instead. + FormUtils.addNewFields(document, List.of(field("text", "ghost")), skipped); + } catch (IOException loadFailure) { + // A parser that refuses the file outright is an equally acceptable outcome. + return; + } + assertFalse(skipped.isEmpty(), "an unreachable page must be reported, not thrown"); + } + + @Test + void aTwoWidgetCheckboxKeepsItsOnStateWhenMoved() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + document.addPage(new PDPage(PDRectangle.LETTER)); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "agree", + "agree", + "checkbox", + 0, + 60f, + 700f, + 14f, + 14f, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null)), + new ArrayList<>()); + FormUtils.modifyFormFields( + document, + List.of( + new FormUtils.ModifyFormFieldDefinition( + "agree", null, null, null, 0, 200f, 400f, null, null, null, + null, null, null, null, null, null, null, null, null))); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDCheckBox box = (PDCheckBox) acroForm.getField("agree"); + assertNotNull(box); + assertFalse(box.getOnValue().isEmpty(), "a moved checkbox must stay tickable"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java new file mode 100644 index 0000000000..a97c06daba --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -0,0 +1,467 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.Set; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.apache.pdfbox.pdmodel.interactive.form.PDVariableText; +import org.junit.jupiter.api.Test; + +/** + * Assertions run after a save/reload cycle: PDFBox synthesises widgets for fields with no explicit + * {@code /Kids}, so only the serialised document reflects what a viewer sees. + */ +class FormUtilsEditingTest { + + private static PDAcroForm setupForm(PDDocument document, PDRectangle pageSize) { + PDPage page = new PDPage(pageSize); + document.addPage(page); + PDAcroForm acroForm = new PDAcroForm(document); + acroForm.setDefaultResources(new PDResources()); + document.getDocumentCatalog().setAcroForm(acroForm); + return acroForm; + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + + private static FormUtils.NewFormFieldDefinition newText( + String name, float x, float y, float w, float h) { + return new FormUtils.NewFormFieldDefinition( + name, null, "text", 0, x, y, w, h, null, null, null, null, null, null, null, null, + null, null); + } + + private static FormUtils.NewFormFieldDefinition newField( + String type, + String name, + float x, + float y, + float w, + float h, + List options, + Integer maxLength, + String buttonAction) { + return new FormUtils.NewFormFieldDefinition( + name, + null, + type, + 0, + x, + y, + w, + h, + null, + null, + options, + null, + null, + null, + null, + null, + maxLength, + buttonAction); + } + + private static PDRectangle firstWidgetRect(PDAcroForm acroForm, String name) { + PDField field = acroForm.getField(name); + assertNotNull(field, "field '" + name + "' should exist"); + assertTrue(!field.getWidgets().isEmpty(), "field should have at least one widget"); + return field.getWidgets().get(0).getRectangle(); + } + + /** + * PDAcroForm.refreshAppearances() never synthesizes /AP for the button family, so without an + * explicit appearance a created checkbox or radio renders blank and resolves to Off. + */ + @Test + void addNewFields_givesToggleFieldsAppearanceStreamsAndKeepsTheirDefault() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("checkbox", "agree", 50, 600, 20, 20, null, null, null), + newField( + "radio", + "choice", + 50, + 500, + 20, + 20, + List.of("Yes", "No"), + null, + null), + newText("fullname", 50, 400, 200, 24))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm); + + // NeedAppearances=false means viewers trust our streams, so they must exist. + assertFalse(acroForm.getNeedAppearances(), "appearance generation should have run"); + + PDField checkBox = acroForm.getField("agree"); + assertTrue(checkBox instanceof PDCheckBox); + assertEquals( + Set.of("Off", "Yes"), + normalStateNames(checkBox.getWidgets().get(0)), + "checkbox needs an Off and an on-state appearance"); + + PDField radio = acroForm.getField("choice"); + assertTrue(radio instanceof PDRadioButton); + assertEquals(2, radio.getWidgets().size()); + assertEquals(Set.of("Off", "Yes"), normalStateNames(radio.getWidgets().get(0))); + assertEquals(Set.of("Off", "No"), normalStateNames(radio.getWidgets().get(1))); + + // A text field's DA names /Helv; if /DR lacks that alias refreshAppearances throws for + // the whole form and every field above loses its appearance too. + PDField text = acroForm.getField("fullname"); + assertNotNull( + text.getWidgets().get(0).getAppearance().getNormalAppearance(), + "text field should have a generated appearance"); + } + } + + /** The /AP /N state names on a widget. */ + private static Set normalStateNames(PDAnnotationWidget widget) { + PDAppearanceDictionary appearance = widget.getAppearance(); + assertNotNull(appearance, "widget should have an /AP dictionary"); + PDAppearanceEntry normal = appearance.getNormalAppearance(); + assertNotNull(normal, "widget should have an /AP /N entry"); + assertTrue(normal.isSubDictionary(), "a toggle needs per-state appearances"); + return normal.getSubDictionary().keySet().stream() + .map(COSName::getName) + .collect(java.util.stream.Collectors.toSet()); + } + + @Test + void addNewFields_createsTextFieldAtRequestedRectangle() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("created", 50, 700, 200, 20))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "AcroForm should exist after reload"); + assertTrue(acroForm.getField("created") instanceof PDTextField); + PDRectangle rect = firstWidgetRect(acroForm, "created"); + assertNotNull(rect, "created widget should keep its rectangle after reload"); + assertEquals(50f, rect.getLowerLeftX(), 0.5f); + assertEquals(700f, rect.getLowerLeftY(), 0.5f); + assertEquals(200f, rect.getWidth(), 0.5f); + assertEquals(20f, rect.getHeight(), 0.5f); + } + } + + @Test + void addNewFields_appliesCropBoxOffsetToCoordinates() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + // Shift the CropBox origin; the frontend sends CropBox-relative coords. + document.getPage(0).setCropBox(new PDRectangle(10, 20, 500, 700)); + FormUtils.addNewFields(document, List.of(newText("shifted", 5, 5, 100, 15))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "shifted"); + // Absolute = CropBox-relative + CropBox lower-left offset. + assertEquals(15f, rect.getLowerLeftX(), 0.5f); + assertEquals(25f, rect.getLowerLeftY(), 0.5f); + } + } + + @Test + void addNewFields_appliesReadOnlyFontSizeAndMultiline() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.NewFormFieldDefinition def = + new FormUtils.NewFormFieldDefinition( + "opts", + null, + "text", + 0, + 10f, + 10f, + 120f, + 18f, + null, + null, + null, + null, + null, + 18f, + Boolean.TRUE, + Boolean.TRUE, + null, + null); + FormUtils.addNewFields(document, List.of(def)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("opts"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + assertTrue(field instanceof PDTextField); + assertTrue(((PDTextField) field).isMultiline(), "multiline flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("18"), "default appearance should carry the font size: " + da); + } + } + + @Test + void modifyFormFields_movesAndResizesWidget() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("movable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "movable", null, null, null, 0, 100f, 600f, 150f, 30f, null, null, null, + null, null, null, null, null, null, null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRectangle rect = firstWidgetRect(acroForm, "movable"); + assertEquals(100f, rect.getLowerLeftX(), 0.5f); + assertEquals(600f, rect.getLowerLeftY(), 0.5f); + assertEquals(150f, rect.getWidth(), 0.5f); + assertEquals(30f, rect.getHeight(), 0.5f); + } + } + + @Test + void modifyFormFields_setsReadOnlyAndFontSize() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("editable", 50, 700, 200, 20))); + + FormUtils.ModifyFormFieldDefinition mod = + new FormUtils.ModifyFormFieldDefinition( + "editable", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 22f, + Boolean.TRUE, + null, + null, + null); + FormUtils.modifyFormFields(document, List.of(mod)); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("editable"); + assertNotNull(field); + assertTrue(field.isReadOnly(), "read-only flag should survive reload"); + String da = ((PDVariableText) field).getDefaultAppearance(); + assertTrue(da.contains("22"), "font size should be reflected in DA: " + da); + } + } + + @Test + void deleteFormFields_removesField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + PDAcroForm acroForm = setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("temp", 50, 700, 200, 20))); + FormUtils.deleteFormFields(document, List.of("temp")); + // After delete the AcroForm may still exist; the field must be gone. + if (acroForm != null) { + assertNull(acroForm.getField("temp")); + } + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue(acroForm == null || acroForm.getField("temp") == null); + } + } + + @Test + void addNewFields_createsRadioGroupWithOneWidgetPerOption() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDField field = acroForm.getField("choice"); + assertNotNull(field, "radio field should exist"); + assertTrue(field instanceof PDRadioButton, "should be a radio button group"); + assertEquals(2, field.getWidgets().size(), "one widget per option"); + assertTrue(((PDRadioButton) field).getExportValues().contains("Yes")); + assertTrue(((PDRadioButton) field).getExportValues().contains("No")); + } + } + + @Test + void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException { + // A radio group's label is its field name, not its first option, so the viewer label + // matches the name shown in the editor. + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "Choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + FormUtils.FormFieldInfo choice = + FormUtils.extractFormFields(reloaded).stream() + .filter(f -> "Choice".equals(f.name())) + .findFirst() + .orElse(null); + assertNotNull(choice, "radio field should be extracted"); + assertEquals( + "Choice", choice.label(), "field name should win over the first option value"); + } + } + + @Test + void addNewFields_createsCombTextField() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, List.of(newField("text", "ssn", 50, 700, 200, 20, null, 9, null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDTextField field = (PDTextField) acroForm.getField("ssn"); + assertNotNull(field); + assertEquals(9, field.getMaxLen(), "comb max length should persist"); + assertTrue(field.isComb(), "comb flag should be set"); + } + } + + @Test + void addNewFields_createsSignatureAndButton() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField("signature", "sig", 50, 600, 200, 60, null, null, null), + newField("button", "btn", 50, 500, 120, 24, null, null, "reset"))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertTrue( + acroForm.getField("sig") instanceof PDSignatureField, + "signature placeholder should exist"); + assertTrue( + acroForm.getField("btn") instanceof PDPushButton, "push button should exist"); + } + } + + @Test + void applyFieldEdits_addsModifiesAndDeletesInOnePass() throws IOException { + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields(document, List.of(newText("old", 50, 700, 200, 20))); + + FormUtils.applyFieldEdits( + document, + List.of(newText("fresh", 50, 600, 200, 20)), + List.of(), + List.of("old")); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm.getField("fresh"), "added field should be present"); + assertNull(acroForm.getField("old"), "deleted field should be gone"); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java index dd828327b1..5c13b13908 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsGapTest.java @@ -705,10 +705,20 @@ class FormUtilsGapTest { "newName", "New Label", null, // keep type (text) -> in-place path + null, + null, + null, + null, + null, Boolean.TRUE, null, null, null, + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -731,7 +741,8 @@ class FormUtilsGapTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "missing", null, null, null, null, null, null, null, null); + "missing", null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -754,7 +765,8 @@ class FormUtilsGapTest { mods.add(null); mods.add( new FormUtils.ModifyFormFieldDefinition( - " ", null, null, null, null, null, null, null, null)); + " ", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null)); FormUtils.modifyFormFields(doc, mods); assertEquals(1, FormUtils.extractFormFields(doc).size()); diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java index f4e013e082..fa3425176a 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsMoreTest.java @@ -285,13 +285,13 @@ class FormUtilsMoreTest { } @Test - void widgetOutOfBoundsYieldsNullCoordinateEntry() throws IOException { + void widgetOutOfBoundsStillReportsItsCoordinates() throws IOException { try (PDDocument doc = new PDDocument()) { SetupDocument setup = createBasicDocument(doc); PDTextField text = new PDTextField(setup.acroForm()); text.setPartialName("offpage"); - // Far below the page origin -> finalY exceeds bounds -> createWidgetCoordinates - // returns null, which is still added to the per-field widget list. + // Off the page is legal PDF; dropping it would leave the user unable to drag it + // back. attachWidget(setup, text, new PDRectangle(50, -5000, 200, 20)); List fields = @@ -301,7 +301,8 @@ class FormUtilsMoreTest { fields.get(0).getWidgets(); assertNotNull(widgets); assertEquals(1, widgets.size()); - assertNull(widgets.get(0)); + assertNotNull(widgets.get(0), "a null entry here crashes sorting and the overlay"); + assertEquals(50f, widgets.get(0).getX(), 0.01f); } } @@ -476,8 +477,18 @@ class FormUtilsMoreTest { "combobox", null, null, + null, + null, + null, + null, + null, List.of("One", "Two"), "One", + null, + null, + null, + null, + null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -505,10 +516,20 @@ class FormUtilsMoreTest { null, "listbox", // same type -> in-place path null, + null, + null, + null, + null, + null, Boolean.TRUE, List.of("X", "Y", "Z"), null, - "Choose items"); + "Choose items", + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -529,7 +550,25 @@ class FormUtilsMoreTest { FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "keep", null, null, "bogusType", null, null, null, null, null); + "keep", + null, + null, + "bogusType", + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null); FormUtils.modifyFormFields(doc, List.of(mod)); // The field is preserved unchanged because the target type is unsupported. @@ -554,7 +593,8 @@ class FormUtilsMoreTest { // Rename beta -> alpha; should be uniquified to avoid the collision. FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "beta", "alpha", null, null, null, null, null, null, null); + "beta", "alpha", null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); @@ -575,7 +615,8 @@ class FormUtilsMoreTest { doc.addPage(new PDPage()); FormUtils.ModifyFormFieldDefinition mod = new FormUtils.ModifyFormFieldDefinition( - "x", null, null, null, null, null, null, null, null); + "x", null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null); FormUtils.modifyFormFields(doc, List.of(mod)); } } diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java new file mode 100644 index 0000000000..d8f5e1deb2 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsNoAcroFormTest.java @@ -0,0 +1,102 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDTextField; +import org.junit.jupiter.api.Test; + +/** + * Most real PDFs have no AcroForm at all, so adding the very first field has to build one that + * PDFBox will accept. + */ +class FormUtilsNoAcroFormTest { + + private static final Path PLAIN_PDF = + Path.of("src/test/resources/pdf-ingestion-fixtures/many-tables-test_stress.pdf"); + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float y, List options, String defaultValue) { + // name, label, type, pageIndex, x, y, width, height, required, multiSelect, + // options, defaultValue, tooltip, fontSize, readOnly, multiline, maxLength, buttonAction + return new FormUtils.NewFormFieldDefinition( + name, + name, + type, + 0, + 60f, + y, + 200f, + 20f, + null, + null, + options, + defaultValue, + null, + null, + null, + null, + null, + null); + } + + private static PDDocument loadPlain() throws IOException { + return Loader.loadPDF(Files.readAllBytes(PLAIN_PDF)); + } + + @Test + void plainPdfReallyHasNoAcroForm() throws IOException { + try (PDDocument document = loadPlain()) { + assertNull( + document.getDocumentCatalog().getAcroForm(null), + "fixture must have no AcroForm or this test proves nothing"); + } + } + + @Test + void addsFirstFieldToAPdfWithNoAcroForm() throws IOException { + byte[] saved; + List skipped = new ArrayList<>(); + try (PDDocument document = loadPlain()) { + FormUtils.addNewFields( + document, + List.of( + newField("text", "fullName", 700f, null, "Ada"), + newField("checkbox", "agree", 660f, null, null), + newField("radio", "contact", 600f, List.of("Email", "Post"), null)), + skipped); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + saved = out.toByteArray(); + } + + assertTrue(skipped.isEmpty(), "no field should be skipped: " + skipped); + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + assertNotNull(acroForm, "an AcroForm should have been created"); + assertNotNull(acroForm.getDefaultResources(), "/DR is required for variable text"); + assertTrue( + acroForm.getDefaultAppearance() != null + && !acroForm.getDefaultAppearance().isBlank(), + "/DA is required for variable text"); + PDTextField text = (PDTextField) acroForm.getField("fullName"); + assertNotNull(text, "the text field should exist"); + assertEquals("Ada", text.getValueAsString()); + assertNotNull(acroForm.getField("agree")); + assertNotNull(acroForm.getField("contact")); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java new file mode 100644 index 0000000000..04b317feb8 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsRadioCaptionTest.java @@ -0,0 +1,175 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +/** + * Option captions belong to the viewer, not the page. Drawing them into the content stream left + * orphan text behind on every move and delete, so these pin the page staying clean. + */ +class FormUtilsRadioCaptionTest { + + private static FormUtils.NewFormFieldDefinition newField( + String type, String name, float x, float y, float w, float h, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, 0, x, y, w, h, null, null, options, null, null, null, null, null, + null, null); + } + + private static byte[] save(PDDocument document) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + document.save(out); + return out.toByteArray(); + } + + private static PDDocument blankWithForm() { + PDDocument document = new PDDocument(); + document.addPage(new PDPage(PDRectangle.LETTER)); + document.getDocumentCatalog().setAcroForm(new PDAcroForm(document)); + return document; + } + + private static String textOf(byte[] pdf) throws IOException { + try (PDDocument reloaded = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(reloaded); + } + } + + @Test + void radioOptionsAreNotBakedIntoThePage() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + saved = save(document); + } + + // The caption is the viewer's job; page content cannot follow a widget that moves. + String text = textOf(saved); + assertFalse(text.contains("Email"), "options must not be page content: " + text); + assertFalse(text.contains("Telephone"), "options must not be page content: " + text); + assertFalse(text.contains("Post"), "options must not be page content: " + text); + } + + @Test + void captionsDoNotReplaceTheWidgetsThemselves() throws IOException { + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of(newField("radio", "size", 72, 600, 12, 12, List.of("S", "M", "L")))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + PDAcroForm acroForm = reloaded.getDocumentCatalog().getAcroForm(null); + PDRadioButton radio = (PDRadioButton) acroForm.getField("size"); + assertEquals(3, radio.getWidgets().size(), "one widget per option"); + assertFalse(radio.getExportValues().isEmpty(), "export values must survive"); + } + } + + @Test + void aTextFieldDrawsNoStrayCaption() throws IOException { + // Control: proves the assertions above read the captions and not some unrelated content. + byte[] saved; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, List.of(newField("text", "fullName", 72, 600, 200, 18, null))); + saved = save(document); + } + assertTrue(textOf(saved).isBlank(), "a text field should add no page content"); + } + + @Test + void deletingARadioGroupTakesItsCaptionsWithIt() throws IOException { + byte[] withRadio; + try (PDDocument document = blankWithForm()) { + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "contact", + 72, + 600, + 12, + 12, + List.of("Email", "Telephone", "Post")))); + withRadio = save(document); + } + assertFalse( + textOf(withRadio).contains("Telephone"), + "the group adds no page text to begin with"); + + byte[] afterDelete; + try (PDDocument document = Loader.loadPDF(withRadio)) { + FormUtils.applyFieldEdits(document, List.of(), List.of(), List.of("contact")); + afterDelete = save(document); + } + + String text = textOf(afterDelete); + assertFalse( + text.contains("Telephone"), + "a deleted radio group must not leave its captions on the page: " + text); + } + + @Test + void theDrawnBoxIsTheWholeGroupNotOneOption() { + // A 90pt box used to become a 360pt stack because each option got the full height. + PDRectangle box = new PDRectangle(72f, 500f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, null, null); + + assertEquals(3, rects.size()); + float top = rects.get(0).getUpperRightY(); + float bottom = rects.get(2).getLowerLeftY(); + assertEquals(90f, top - bottom, 0.01f, "the group must fill exactly the drawn height"); + assertEquals( + box.getUpperRightY(), top, 0.01f, "the first option starts at the box's top edge"); + for (PDRectangle r : rects) { + assertEquals(r.getWidth(), r.getHeight(), 0.01f, "options stay square"); + assertTrue(r.getWidth() <= box.getWidth() + 0.01f, "an option never exceeds the box"); + } + } + + @Test + void explicitSizeAndGapWin() { + PDRectangle box = new PDRectangle(0f, 0f, 100f, 90f); + var rects = FormUtils.radioOptionRects(box, 3, 20f, 14f); + for (PDRectangle r : rects) { + assertEquals(14f, r.getHeight(), 0.01f, "the requested size is used verbatim"); + } + float gap = rects.get(0).getLowerLeftY() - rects.get(1).getUpperRightY(); + assertEquals(20f, gap, 0.01f, "the requested gap is used verbatim"); + } + + @Test + void aSingleOptionStillFitsTheBox() { + var rects = FormUtils.radioOptionRects(new PDRectangle(0f, 0f, 40f, 40f), 1, null, null); + assertEquals(1, rects.size()); + assertTrue(rects.get(0).getHeight() <= 40f, "one option cannot exceed its box"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java new file mode 100644 index 0000000000..8c8d7e14be --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/util/MissingDefaultResourcesTest.java @@ -0,0 +1,57 @@ +package stirling.software.common.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A form with no default resources is ordinary; adding a field to it must still work. */ +class MissingDefaultResourcesTest { + + @Test + @DisplayName("a text field can be added to a form that has no default resources") + void addsToFormWithoutDefaultResources() throws IOException { + // A real upload arrives as bytes, and plenty of forms in the wild carry no /DR at all. + byte[] pdf; + try (PDDocument built = new PDDocument(); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) { + built.addPage(new PDPage(PDRectangle.A4)); + PDAcroForm form = new PDAcroForm(built); + // A /DA naming a font with no /DR to resolve it is what PDFBox refuses. + form.setDefaultAppearance("/Helv 0 Tf 0 g"); + form.getCOSObject().removeItem(org.apache.pdfbox.cos.COSName.DR); + built.getDocumentCatalog().setAcroForm(form); + built.save(out); + pdf = out.toByteArray(); + } + + try (PDDocument document = org.apache.pdfbox.Loader.loadPDF(pdf)) { + + List skipped = new ArrayList<>(); + FormUtils.addNewFields( + document, + List.of( + new FormUtils.NewFormFieldDefinition( + "note", null, "text", 0, 50f, 700f, 200f, 20f, null, null, null, + null, null, null, null, null, null, null)), + skipped); + + assertTrue( + skipped.isEmpty(), + "adding a plain text field should not be refused: " + skipped); + assertEquals( + 1, + FormUtils.extractFormFields(document).size(), + "the field should be in the document"); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 2726f8d764..01d3f49e2c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -183,7 +183,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } else if (hasConfiguredOrigins) { @@ -229,7 +231,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } else { @@ -256,7 +260,9 @@ public class WebMvcConfig implements WebMvcConfigurer { "X-Page-Number", "X-Page-Size", "Content-Disposition", - "Content-Type") + "Content-Type", + "X-Stirling-Skipped-Field-Edits", + "X-Stirling-Skipped-Field-Edits-Total") .allowCredentials(true) .maxAge(3600); } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java index d24d175f5c..0bd3daf180 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java @@ -2,10 +2,20 @@ package stirling.software.SPDF.controller.api.form; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.StringWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.poi.ss.usermodel.*; @@ -35,6 +45,7 @@ import stirling.software.common.model.FormFieldWithCoordinates; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.WebResponseUtils; @@ -59,6 +70,25 @@ import tools.jackson.databind.ObjectMapper; @RequiredArgsConstructor public class FormFillController { + /** Carries the edits a request asked for but the document could not take, as base64 JSON. */ + public static final String SKIPPED_EDITS_HEADER = "X-Stirling-Skipped-Field-Edits"; + + /** How many were skipped in total, which may exceed the number listed in the header above. */ + public static final String SKIPPED_EDITS_TOTAL_HEADER = "X-Stirling-Skipped-Field-Edits-Total"; + + /** Keeps the header well inside Jetty's response-header budget. */ + private static final int MAX_REPORTED_SKIPS = 20; + + /** Bytes of encoded header value, well under the container's limit for the whole header set. */ + private static final int MAX_SKIP_HEADER_BYTES = 4096; + + private static final int MAX_SKIP_FIELD_CHARS = 120; + + /** Entry names inside the {@code ?includeFields=true} bundle. */ + private static final String FIELDS_ENTRY = "fields.json"; + + private static final String DOCUMENT_ENTRY = "document.pdf"; + private final CustomPDFDocumentFactory pdfDocumentFactory; private final ObjectMapper objectMapper; private final TempFileManager tempFileManager; @@ -68,6 +98,72 @@ public class FormFillController { return WebResponseUtils.pdfDocToWebResponse(document, baseName + ".pdf", tempFileManager); } + /** + * Rejects field names PDFBox cannot store before the document is touched, so the caller gets a + * 400 naming the offending character instead of a 200 with the field quietly missing. + */ + private static void requireUsableFieldNames( + List adds, + List modifies) { + Stream problems = + Stream.concat( + adds.stream() + .map(FormUtils.NewFormFieldDefinition::name) + .map(FormUtils::invalidFieldNameReason), + // A rename to the same name is not a rename, so a nested field whose + // qualified name already contains a period is left alone. + modifies.stream() + .map(m -> FormUtils.renameProblem(m.targetName(), m.name()))); + problems.filter(Objects::nonNull) + .findFirst() + .ifPresent( + reason -> { + throw ExceptionUtils.createIllegalArgumentException( + "error.invalidArgument", "{0}", reason); + }); + } + + /** + * The body is the updated PDF, so dropped edits travel as a base64 JSON header; + * percent-encoding would turn every space into a plus sign. + */ + private ResponseEntity withSkippedEdits( + ResponseEntity response, List skipped) { + if (skipped.isEmpty()) { + return response; + } + // A count cap alone is not enough: one very long field name can still overflow the + // header budget and turn the response into an error page, losing the edited PDF. + List reported = new ArrayList<>(); + String encoded = ""; + for (FormUtils.SkippedFieldEdit edit : skipped) { + if (reported.size() >= MAX_REPORTED_SKIPS) { + break; + } + reported.add( + new FormUtils.SkippedFieldEdit( + edit.operation(), + FormUtils.abbreviate(edit.target(), MAX_SKIP_FIELD_CHARS), + FormUtils.abbreviate(edit.reason(), MAX_SKIP_FIELD_CHARS))); + String candidate = + Base64.getEncoder() + .encodeToString( + objectMapper + .writeValueAsString(reported) + .getBytes(StandardCharsets.UTF_8)); + if (candidate.length() > MAX_SKIP_HEADER_BYTES) { + reported.removeLast(); + break; + } + encoded = candidate; + } + return ResponseEntity.status(response.getStatusCode()) + .headers(response.getHeaders()) + .header(SKIPPED_EDITS_TOTAL_HEADER, String.valueOf(skipped.size())) + .header(SKIPPED_EDITS_HEADER, encoded) + .body(response.getBody()); + } + private static String buildBaseName(MultipartFile file, String suffix) { String original = Filenames.toSimpleFileName(file.getOriginalFilename()); if (original == null || original.isBlank()) { @@ -257,6 +353,110 @@ public class FormFillController { } } + @PostMapping(value = "/add-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Add new form fields", + description = + "Creates new form fields in the provided PDF and returns the updated file") + public ResponseEntity addFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = "JSON array of new field definitions", + example = + "[{\"name\":\"NewField\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]") + @RequestPart(value = "fields", required = false) + byte[] fieldsPayload) + throws IOException { + + String rawFields = decodePart(fieldsPayload); + List definitions = + FormPayloadParser.parseNewFieldDefinitions(objectMapper, rawFields); + if (definitions.isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", + "{0} must contain at least one definition", + "fields payload"); + } + + requireUsableFieldNames(definitions, List.of()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.addNewFields(document, definitions, skipped)), + skipped); + } + + @PostMapping(value = "/edit-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Apply a batch of form field edits", + description = + "Adds, modifies, and deletes form fields in a single request (one document" + + " load/save) and returns the updated file") + public ResponseEntity editFields( + @Parameter( + description = "The input PDF file", + required = true, + content = + @Content( + mediaType = MediaType.APPLICATION_PDF_VALUE, + schema = @Schema(type = "string", format = "binary"))) + @RequestParam("file") + MultipartFile file, + @Parameter( + description = + "JSON object with optional 'add', 'modify' and 'delete'" + + " sections", + example = + "{\"add\":[{\"name\":\"f\",\"type\":\"text\",\"pageIndex\":0," + + "\"x\":50,\"y\":700,\"width\":200,\"height\":20}]," + + "\"modify\":[],\"delete\":[]}") + @RequestPart(value = "edits", required = false) + byte[] editsPayload, + @Parameter( + description = + "Return a ZIP holding the updated PDF plus the field list it" + + " produced, instead of the bare PDF. Saves re-uploading" + + " the result just to read its fields back.") + @RequestParam(value = "includeFields", defaultValue = "false") + boolean includeFields) + throws IOException { + + String rawEdits = decodePart(editsPayload); + FormUtils.FieldEditBatch batch = FormPayloadParser.parseFieldEdits(objectMapper, rawEdits); + if (batch.add().isEmpty() && batch.modify().isEmpty() && batch.delete().isEmpty()) { + throw ExceptionUtils.createIllegalArgumentException( + "error.dataRequired", "{0} must contain at least one edit", "edits payload"); + } + requireUsableFieldNames(batch.add(), batch.modify()); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + includeFields, + document -> + FormUtils.applyFieldEdits( + document, + batch.add(), + batch.modify(), + batch.delete(), + skipped)), + skipped); + } + @PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Modify existing form fields", @@ -285,8 +485,15 @@ public class FormFillController { "updates payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.modifyFormFields(document, modifications)); + requireUsableFieldNames(List.of(), modifications); + + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.modifyFormFields(document, modifications, skipped)), + skipped); } @PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -319,8 +526,13 @@ public class FormFillController { "error.dataRequired", "{0} must contain at least one value", "names payload"); } - return processSingleFile( - file, "updated", document -> FormUtils.deleteFormFields(document, names)); + List skipped = new ArrayList<>(); + return withSkippedEdits( + processSingleFile( + file, + "updated", + document -> FormUtils.deleteFormFields(document, names, skipped)), + skipped); } @PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -358,13 +570,81 @@ public class FormFillController { private ResponseEntity processSingleFile( MultipartFile file, String suffix, DocumentProcessor processor) throws IOException { + return processSingleFile(file, suffix, false, processor); + } + + private ResponseEntity processSingleFile( + MultipartFile file, String suffix, boolean includeFields, DocumentProcessor processor) + throws IOException { requirePdf(file); String baseName = buildBaseName(file, suffix); try (PDDocument document = pdfDocumentFactory.load(file)) { FormUtils.repairMissingWidgetPageReferences(document); processor.accept(document); - return saveDocument(document, baseName); + return includeFields + ? saveDocumentWithFields(document, baseName) + : saveDocument(document, baseName); + } + } + + /** + * Answers "what fields does the saved file have?" from the document still open here, so the + * caller does not have to upload the result back to ask. + */ + private ResponseEntity saveDocumentWithFields(PDDocument document, String baseName) + throws IOException { + TempFile zip = null; + boolean zipTransferred = false; + try (TempFile pdf = tempFileManager.createManagedTempFile(".pdf")) { + document.save(pdf.getFile()); + // Read the fields after the save so they describe the bytes actually being returned. + byte[] fields = + objectMapper.writeValueAsBytes( + FormUtils.extractFormFieldsWithCoordinates(document)); + zip = tempFileManager.createManagedTempFile(".zip"); + writeFieldBundle(zip.getPath(), pdf.getPath(), fields); + ResponseEntity response = + WebResponseUtils.zipFileToWebResponse(zip, baseName + ".zip"); + zipTransferred = true; + return response; + } finally { + if (zip != null && !zipTransferred) { + zip.close(); + } + } + } + + /** + * Deflates the JSON because it is text, but stores the PDF: its streams are already compressed, + * so deflating costs ~25ms per MB to save a few percent. + */ + private static void writeFieldBundle(Path zipPath, Path pdfPath, byte[] fields) + throws IOException { + long pdfSize = Files.size(pdfPath); + CRC32 crc = new CRC32(); + try (InputStream in = Files.newInputStream(pdfPath)) { + byte[] buffer = new byte[8192]; + for (int read; (read = in.read(buffer)) != -1; ) { + crc.update(buffer, 0, read); + } + } + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(zipPath))) { + ZipEntry fieldsEntry = new ZipEntry(FIELDS_ENTRY); + fieldsEntry.setMethod(ZipEntry.DEFLATED); + zip.putNextEntry(fieldsEntry); + zip.write(fields); + zip.closeEntry(); + + ZipEntry documentEntry = new ZipEntry(DOCUMENT_ENTRY); + documentEntry.setMethod(ZipEntry.STORED); + documentEntry.setSize(pdfSize); + documentEntry.setCompressedSize(pdfSize); + documentEntry.setCrc(crc.getValue()); + zip.putNextEntry(documentEntry); + Files.copy(pdfPath, zip); + zip.closeEntry(); + zip.finish(); } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index 6f82c7546e..f48f419a6d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -28,6 +28,8 @@ final class FormPayloadParser { private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; private static final TypeReference> MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> NEW_FIELD_LIST_TYPE = + new TypeReference<>() {}; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; private FormPayloadParser() {} @@ -94,6 +96,43 @@ final class FormPayloadParser { return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE); } + static List parseNewFieldDefinitions( + ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + return objectMapper.readValue(json, NEW_FIELD_LIST_TYPE); + } + + /** + * Parses a combined edit batch: {@code {"add":[...],"modify":[...],"delete":[...]}}. Each + * section is optional. The delete section accepts the same shapes as {@link #parseNameList}. + */ + static FormUtils.FieldEditBatch parseFieldEdits(ObjectMapper objectMapper, String json) { + if (json == null || json.isBlank()) { + return new FormUtils.FieldEditBatch(List.of(), List.of(), List.of()); + } + final JsonNode root = objectMapper.readTree(json); + List adds = List.of(); + List modifies = List.of(); + List deletes = List.of(); + if (root != null && root.isObject()) { + final JsonNode addNode = root.get("add"); + if (addNode != null && addNode.isArray()) { + adds = objectMapper.readValue(addNode.toString(), NEW_FIELD_LIST_TYPE); + } + final JsonNode modifyNode = root.get("modify"); + if (modifyNode != null && modifyNode.isArray()) { + modifies = objectMapper.readValue(modifyNode.toString(), MODIFY_FIELD_LIST_TYPE); + } + final JsonNode deleteNode = root.get("delete"); + if (deleteNode != null && !deleteNode.isNull()) { + deletes = parseNameList(objectMapper, deleteNode.toString()); + } + } + return new FormUtils.FieldEditBatch(adds, modifies, deletes); + } + static List parseNameList(ObjectMapper objectMapper, String json) { if (json == null || json.isBlank()) { return List.of(); diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java new file mode 100644 index 0000000000..8082949c8e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFieldBundleTest.java @@ -0,0 +1,374 @@ +package stirling.software.SPDF.controller.api.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm; +import org.apache.pdfbox.pdmodel.interactive.form.PDField; +import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; + +import stirling.software.common.model.FormFieldWithCoordinates; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.FormUtils; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; + +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * Drives ?includeFields=true across a spread of real form shapes, checking the bundled list stays + * interchangeable with the follow-up request it exists to remove. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("edit-fields field bundle") +class FormFieldBundleTest { + + /** Set to a directory to dump the produced archives for the frontend reader's fixtures. */ + private static final String FIXTURE_DIR = System.getProperty("bundle.fixtures"); + + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private TempFileManager tempFileManager; + @InjectMocks private FormFillController controller; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws Exception { + lenient() + .when(tempFileManager.createManagedTempFile(anyString())) + .thenAnswer( + invocation -> { + File file = + Files.createTempFile( + "bundle", invocation.getArgument(0)) + .toFile(); + TempFile temp = mock(TempFile.class); + lenient().when(temp.getFile()).thenReturn(file); + lenient().when(temp.getPath()).thenReturn(file.toPath()); + return temp; + }); + objectMapper = JsonMapper.builder().build(); + var field = FormFillController.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(controller, objectMapper); + } + + // -- document shapes ---------------------------------------------- + + private record Style( + String name, int pages, int rotation, List fields) {} + + private static FormUtils.NewFormFieldDefinition field( + String name, String type, int page, float y, List options) { + return new FormUtils.NewFormFieldDefinition( + name, null, type, page, 50f, y, 200f, 20f, null, null, options, null, null, null, + null, null, null, null); + } + + static List