mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-30 10:01:42 +02:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 580302cbd2 | |||
| 3b9c311dc5 | |||
| 016fcba1c6 | |||
| 09adcda2d9 | |||
| 18b983bfe5 | |||
| 9ffd8d4810 | |||
| 55d0066efd | |||
| 1de0a5942c | |||
| fd0ca6d455 | |||
| 84b89e2708 |
Generated
+2
@@ -10052,6 +10052,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"yaak-core",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
@@ -10182,6 +10183,7 @@ dependencies = [
|
|||||||
"webbrowser",
|
"webbrowser",
|
||||||
"yaak",
|
"yaak",
|
||||||
"yaak-api",
|
"yaak-api",
|
||||||
|
"yaak-core",
|
||||||
"yaak-crypto",
|
"yaak-crypto",
|
||||||
"yaak-http",
|
"yaak-http",
|
||||||
"yaak-models",
|
"yaak-models",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -90,10 +89,6 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommercialUseBanner source="git-clone" title="Using Git for work?">
|
|
||||||
A Yaak license is required for commercial use and helps support features like this.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
|
|
||||||
<PlainInput
|
<PlainInput
|
||||||
required
|
required
|
||||||
label="Repository URL"
|
label="Repository URL"
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
import { pricingUrl } from "../lib/pricingUrl";
|
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
|
||||||
|
|
||||||
const COMMERCIAL_USE_SNOOZE_DAYS = 7;
|
|
||||||
|
|
||||||
export function CommercialUseBanner({
|
|
||||||
children,
|
|
||||||
source,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
children: string;
|
|
||||||
source: string;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const {
|
|
||||||
isLoading: isSnoozeLoading,
|
|
||||||
set: setSnoozedAt,
|
|
||||||
value: snoozedAt,
|
|
||||||
} = useKeyValue<string | null>({
|
|
||||||
namespace: "global",
|
|
||||||
key: "commercial-use-banner-snoozed-at",
|
|
||||||
fallback: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let canceled = false;
|
|
||||||
|
|
||||||
shouldShowCommercialUsePrompt()
|
|
||||||
.then((shouldShow) => {
|
|
||||||
if (!canceled) setVisible(shouldShow);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [source]);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!visible ||
|
|
||||||
isSnoozeLoading ||
|
|
||||||
isWithinDays(snoozedAt, COMMERCIAL_USE_SNOOZE_DAYS)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<DismissibleBanner
|
|
||||||
id={`commercial-use:${source}`}
|
|
||||||
color="info"
|
|
||||||
className="w-full"
|
|
||||||
onDismiss={() => setSnoozedAt(new Date().toISOString())}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "View plans",
|
|
||||||
color: "info",
|
|
||||||
variant: "solid",
|
|
||||||
onClick: () => {
|
|
||||||
openCommercialUsePricing(source).catch(console.error);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div className="text-sm">
|
|
||||||
<p className="font-semibold text-text">{title}</p>
|
|
||||||
<p className="mt-0.5 text-text-subtle">{children}</p>
|
|
||||||
</div>
|
|
||||||
</DismissibleBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
|
||||||
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
|
|
||||||
if (appInfo.featureLicense !== true) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
|
|
||||||
return license.status !== "active" && license.status !== "trialing";
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Failed to check license before commercial-use prompt", err);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCommercialUsePricing(source: string): Promise<void> {
|
|
||||||
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinDays(date: string | null, days: number): boolean {
|
|
||||||
if (date == null) return false;
|
|
||||||
|
|
||||||
const time = new Date(date).getTime();
|
|
||||||
if (Number.isNaN(time)) return false;
|
|
||||||
|
|
||||||
return Date.now() - time < days * 24 * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
@@ -130,7 +130,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
|||||||
return key !== nextCookieKey;
|
return key !== nextCookieKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
void patchModel(cookieJar, { cookies: [...nextCookies, nextCookie] });
|
||||||
setSelectedCookieKey(nextCookieKey);
|
setSelectedCookieKey(nextCookieKey);
|
||||||
setEditingCookieKey(null);
|
setEditingCookieKey(null);
|
||||||
setDraftCookie(null);
|
setDraftCookie(null);
|
||||||
@@ -210,7 +210,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
|||||||
setEditingCookieKey(null);
|
setEditingCookieKey(null);
|
||||||
setDraftCookie(null);
|
setDraftCookie(null);
|
||||||
setDraftExpiresInput("");
|
setDraftExpiresInput("");
|
||||||
patchModel(cookieJar, { cookies: [] });
|
void patchModel(cookieJar, { cookies: [] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableHeaderCell>
|
</TableHeaderCell>
|
||||||
@@ -276,7 +276,7 @@ export const CookieDialog = ({ cookieJarId }: Props) => {
|
|||||||
setDraftCookie(null);
|
setDraftCookie(null);
|
||||||
setDraftExpiresInput("");
|
setDraftExpiresInput("");
|
||||||
}
|
}
|
||||||
patchModel(cookieJar, {
|
void patchModel(cookieJar, {
|
||||||
cookies: cookieJar.cookies.filter(
|
cookies: cookieJar.cookies.filter(
|
||||||
(c2: Cookie) => cookieKey(c2) !== key,
|
(c2: Cookie) => cookieKey(c2) !== key,
|
||||||
),
|
),
|
||||||
@@ -570,6 +570,8 @@ function CookieTextInput({
|
|||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
className={cookieInputClassName}
|
className={cookieInputClassName}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
@@ -585,6 +587,8 @@ function CookieTextInput({
|
|||||||
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
function CookieTextarea({ onChange, value }: { onChange: (value: string) => void; value: string }) {
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
className={classNames(cookieInputClassName, "min-h-[5rem] resize-y")}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import slugify from "slugify";
|
|||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
@@ -86,12 +85,8 @@ function ExportDataDialogContent({
|
|||||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||||
const noneSelected = numSelected === 0;
|
const noneSelected = numSelected === 0;
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
|
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
<VStack space={3} className="overflow-auto px-5 pb-6">
|
||||||
<CommercialUseBanner source="data-export" title="Exporting work data?">
|
|
||||||
A Yaak license is required for commercial use and helps support features like this.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
|
|
||||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -142,9 +137,9 @@ function ExportDataDialogContent({
|
|||||||
/>
|
/>
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
</VStack>
|
</VStack>
|
||||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
|
||||||
<div>
|
<div>
|
||||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
|
||||||
Create Run Button
|
Create Run Button
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
|
||||||
@@ -15,10 +14,6 @@ export function ImportDataDialog({ importData }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={5} className="pb-4">
|
<VStack space={5} className="pb-4">
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?">
|
|
||||||
A Yaak license is required for commercial use and helps support features like this.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
|
|
||||||
<VStack space={1}>
|
<VStack space={1}>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>OpenAPI 3.0, 3.1</li>
|
<li>OpenAPI 3.0, 3.1</li>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { showConfirmDelete } from "../../lib/confirm";
|
import { showConfirmDelete } from "../../lib/confirm";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import { DetailsBanner } from "../core/DetailsBanner";
|
import { DetailsBanner } from "../core/DetailsBanner";
|
||||||
@@ -233,10 +232,6 @@ export function SettingsCertificates() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?">
|
|
||||||
A Yaak license is required for commercial use and helps support features like this.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
|
|
||||||
{certificates.length > 0 && (
|
{certificates.length > 0 && (
|
||||||
<VStack space={3}>
|
<VStack space={3}>
|
||||||
{certificates.map((cert, index) => (
|
{certificates.map((cert, index) => (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "../../lib/requestSettings";
|
} from "../../lib/requestSettings";
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import {
|
import {
|
||||||
ModelSettingRowBoolean,
|
ModelSettingRowBoolean,
|
||||||
@@ -39,15 +38,10 @@ export function SettingsGeneral() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<Heading>General</Heading>
|
<Heading>General</Heading>
|
||||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 mb-5">
|
|
||||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?">
|
|
||||||
A Yaak license is required for commercial use and helps support future development.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
</div>
|
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
<CargoFeature feature="updater">
|
<CargoFeature feature="updater">
|
||||||
<SettingsSection title="Updates">
|
<SettingsSection title="Updates">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
import { invokeCmd } from "../../lib/tauri";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
@@ -253,9 +252,7 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
Licenses help keep Yaak independent and sustainable.{" "}
|
||||||
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||||
Purchase a License →
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { formatDate } from "date-fns/format";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
import { useToggle } from "../../hooks/useToggle";
|
||||||
import { pluralizeCount } from "../../lib/pluralize";
|
import { pluralizeCount } from "../../lib/pluralize";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
@@ -49,7 +48,7 @@ function SettingsLicenseCmp() {
|
|||||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +68,7 @@ function SettingsLicenseCmp() {
|
|||||||
</span>
|
</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +134,7 @@ function SettingsLicenseCmp() {
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")}
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
>
|
>
|
||||||
Direct Support
|
Direct Support
|
||||||
@@ -151,7 +150,9 @@ function SettingsLicenseCmp() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
openUrl(
|
||||||
|
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Purchase License
|
Purchase License
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import {
|
import {
|
||||||
SettingRowBoolean,
|
SettingRowBoolean,
|
||||||
SettingRowSelect,
|
SettingRowSelect,
|
||||||
@@ -34,9 +33,6 @@ export function SettingsProxy() {
|
|||||||
traffic, or routing through specific infrastructure.
|
traffic, or routing through specific infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?">
|
|
||||||
A Yaak license is required for commercial use and helps support features like this.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
<SettingsSection title="Proxy">
|
<SettingsSection title="Proxy">
|
||||||
<SettingRowSelect
|
<SettingRowSelect
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useExportData } from "../hooks/useExportData";
|
|||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { importData } from "../lib/importData";
|
import { importData } from "../lib/importData";
|
||||||
import { pricingUrl } from "../lib/pricingUrl";
|
|
||||||
import type { DropdownRef } from "./core/Dropdown";
|
import type { DropdownRef } from "./core/Dropdown";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
import { Dropdown } from "./core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -77,8 +76,7 @@ export function SettingsDropdown() {
|
|||||||
hidden: check.data == null || check.data.status === "active",
|
hidden: check.data == null || check.data.status === "active",
|
||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||||
onSelect: () =>
|
onSelect: () => openUrl("https://yaak.app/pricing"),
|
||||||
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Install CLI",
|
label: "Install CLI",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { parseBulkPairLine } from "./BulkPairEditor";
|
||||||
|
|
||||||
|
describe("parseBulkPairLine", () => {
|
||||||
|
test("parses colon-space pairs as name and value", () => {
|
||||||
|
expect(parseBulkPairLine("foo: bar")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "foo",
|
||||||
|
value: "bar",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves colon-without-space lines as a name with an empty value", () => {
|
||||||
|
expect(parseBulkPairLine("foo:bar")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "foo:bar",
|
||||||
|
value: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves malformed lines instead of dropping their contents", () => {
|
||||||
|
expect(parseBulkPairLine("not a pair")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "not a pair",
|
||||||
|
value: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unescapes newlines in parsed values", () => {
|
||||||
|
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
|
||||||
|
enabled: true,
|
||||||
|
name: "foo",
|
||||||
|
value: "bar\nbaz",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,7 @@ export function BulkPairEditor({
|
|||||||
const pairsText = useMemo(() => {
|
const pairsText = useMemo(() => {
|
||||||
return pairs
|
return pairs
|
||||||
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
|
||||||
.map(pairToLine)
|
.map(formatBulkPairLine)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
}, [pairs]);
|
}, [pairs]);
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export function BulkPairEditor({
|
|||||||
const pairs = text
|
const pairs = text
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter((l: string) => l.trim())
|
.filter((l: string) => l.trim())
|
||||||
.map(lineToPair);
|
.map(parseBulkPairLine);
|
||||||
onChange(pairs);
|
onChange(pairs);
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange],
|
||||||
@@ -47,16 +47,16 @@ export function BulkPairEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pairToLine(pair: Pair) {
|
export function formatBulkPairLine(pair: Pair) {
|
||||||
const value = pair.value.replaceAll("\n", "\\n");
|
const value = pair.value.replaceAll("\n", "\\n");
|
||||||
return `${pair.name}: ${value}`;
|
return `${pair.name}: ${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineToPair(line: string): PairWithId {
|
export function parseBulkPairLine(line: string): PairWithId {
|
||||||
const [, name, value] = line.match(/^(:?[^:]+):\s+(.*)$/) ?? [];
|
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
name: (name ?? "").trim(),
|
name: (name ?? line).trim(),
|
||||||
value: (value ?? "").replaceAll("\\n", "\n").trim(),
|
value: (value ?? "").replaceAll("\\n", "\n").trim(),
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,62 +1,39 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
import { Banner, HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
import type { ButtonProps } from "./Button";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
export function DismissibleBanner({
|
export function DismissibleBanner({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
id,
|
id,
|
||||||
onDismiss,
|
|
||||||
actions,
|
actions,
|
||||||
...props
|
...props
|
||||||
}: BannerProps & {
|
}: BannerProps & {
|
||||||
id: string;
|
id: string;
|
||||||
onDismiss?: () => void | Promise<void>;
|
actions?: { label: string; onClick: () => void; color?: Color }[];
|
||||||
actions?: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
color?: Color;
|
|
||||||
variant?: ButtonProps["variant"];
|
|
||||||
}[];
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
|
||||||
isLoading,
|
|
||||||
set: setDismissed,
|
|
||||||
value: dismissed,
|
|
||||||
} = useKeyValue<boolean>({
|
|
||||||
namespace: "global",
|
namespace: "global",
|
||||||
key: ["dismiss-banner", id],
|
key: ["dismiss-banner", id],
|
||||||
fallback: false,
|
fallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || dismissed) return null;
|
if (dismissed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner className={classNames(className, "relative")} {...props}>
|
<Banner
|
||||||
<div className="@container">
|
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
|
||||||
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
|
{...props}
|
||||||
{children}
|
|
||||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color={props.color}
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
|
||||||
setDismissed(true).catch(console.error);
|
|
||||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
|
||||||
}}
|
|
||||||
title="Dismiss message"
|
|
||||||
>
|
>
|
||||||
Dismiss
|
{children}
|
||||||
</Button>
|
<HStack space={1.5}>
|
||||||
{actions?.map((a) => (
|
{actions?.map((a) => (
|
||||||
<Button
|
<Button
|
||||||
key={a.label}
|
key={a.label}
|
||||||
variant={a.variant ?? "border"}
|
variant="border"
|
||||||
color={a.color ?? props.color}
|
color={a.color ?? props.color}
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={a.onClick}
|
onClick={a.onClick}
|
||||||
@@ -65,9 +42,16 @@ export function DismissibleBanner({
|
|||||||
{a.label}
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
<Button
|
||||||
</div>
|
variant="border"
|
||||||
</div>
|
color={props.color}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setDismissed((d) => !d)}
|
||||||
|
title="Dismiss message"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -580,6 +580,10 @@ function getExtensions({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
...baseExtensions, // Must be first
|
...baseExtensions, // Must be first
|
||||||
|
EditorView.contentAttributes.of({
|
||||||
|
autocapitalize: "off",
|
||||||
|
autocorrect: "off",
|
||||||
|
}),
|
||||||
EditorView.domEventHandlers({
|
EditorView.domEventHandlers({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
onFocus.current?.();
|
onFocus.current?.();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@top pairs { (Key Sep Value "\n")* }
|
@top pairs { (Key Sep Value "\n")* }
|
||||||
|
|
||||||
@tokens {
|
@tokens {
|
||||||
Sep { ":" }
|
Sep { ":" $[ \t]+ }
|
||||||
Key { ":"? ![:]+ }
|
Key { ":"? ![:]+ }
|
||||||
Value { ![\n]+ }
|
Value { ![\n]+ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { parser } from "./pairs";
|
||||||
|
|
||||||
|
function getNodeNames(input: string): string[] {
|
||||||
|
const tree = parser.parse(input);
|
||||||
|
const nodes: string[] = [];
|
||||||
|
const cursor = tree.cursor();
|
||||||
|
do {
|
||||||
|
if (cursor.name !== "pairs") {
|
||||||
|
nodes.push(cursor.name);
|
||||||
|
}
|
||||||
|
} while (cursor.next());
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("pairs grammar", () => {
|
||||||
|
test("parses colon-space pairs with a value", () => {
|
||||||
|
expect(getNodeNames("foo: bar\n")).toEqual(["Key", "Sep", "Value"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not parse colon-without-space as a value", () => {
|
||||||
|
const nodes = getNodeNames("foo:bar\n");
|
||||||
|
|
||||||
|
expect(nodes).not.toContain("Value");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ export const parser = LRParser.deserialize({
|
|||||||
skippedNodes: [0],
|
skippedNodes: [0],
|
||||||
repeatNodeCount: 1,
|
repeatNodeCount: 1,
|
||||||
tokenData:
|
tokenData:
|
||||||
"$]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#vVRQSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
"%]VRVOYhYZ#[Z![h![!]#o!];'Sh;'S;=`#U<%lOhToVQPSSOYhYZ!UZ![h![!]!m!];'Sh;'S;=`#U<%lOhP!ZSQPO![!U!];'S!U;'S;=`!g<%lO!UP!jP;=`<%l!US!rSSSOY!mZ;'S!m;'S;=`#O<%lO!mS#RP;=`<%l!mT#XP;=`<%lhR#cSVQQPO![!U!];'S!U;'S;=`!g<%lO!UV#tYSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOhV$mYQPRQSSOXhXY$dYZ!UZphpq$dq![h![!]!m!];'Sh;'S;=`#U<%lOh",
|
||||||
tokenizers: [0, 1, 2],
|
tokenizers: [0, 1, 2],
|
||||||
topRules: { pairs: [0, 1] },
|
topRules: { pairs: [0, 1] },
|
||||||
tokenPrec: 0,
|
tokenPrec: 0,
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export function KeyValueRow({
|
|||||||
const textToCopy =
|
const textToCopy =
|
||||||
copyText ??
|
copyText ??
|
||||||
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
|
||||||
|
const copyTitle =
|
||||||
|
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
|
||||||
const resolvedRightSlot =
|
const resolvedRightSlot =
|
||||||
rightSlot ??
|
rightSlot ??
|
||||||
(enableCopy && textToCopy != null ? (
|
(enableCopy && textToCopy != null ? (
|
||||||
@@ -62,7 +64,7 @@ export function KeyValueRow({
|
|||||||
text={textToCopy}
|
text={textToCopy}
|
||||||
className="text-text-subtle"
|
className="text-text-subtle"
|
||||||
size="2xs"
|
size="2xs"
|
||||||
title={`Copy ${label}`}
|
title={copyTitle}
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
/>
|
/>
|
||||||
) : null);
|
) : null);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
import { useGitFileDiffForCommit, useGitLog, useGitMutations } from "@yaakapp-internal/git";
|
||||||
import type { GitCommit } from "@yaakapp-internal/git";
|
import type { GitCommit } from "@yaakapp-internal/git";
|
||||||
import { InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
import { SplitLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { formatDistanceToNowStrict } from "date-fns";
|
import { formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, IconButton, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { modelToYaml } from "../../lib/diffYaml";
|
import { modelToYaml } from "../../lib/diffYaml";
|
||||||
@@ -16,7 +16,6 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
|
|||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
import { sync } from "../../init/sync";
|
import { sync } from "../../init/sync";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -206,10 +205,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultRatio={0.6}
|
defaultRatio={0.6}
|
||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
<div style={style} className="h-full px-4">
|
||||||
<CommercialUseBanner source="git-commit" title="Using Git for work?">
|
|
||||||
A Yaak license is required for commercial use and helps support features like this.
|
|
||||||
</CommercialUseBanner>
|
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
storageKey="commit-vertical"
|
storageKey="commit-vertical"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
|
|||||||
title: "Add Remote",
|
title: "Add Remote",
|
||||||
inputs: [
|
inputs: [
|
||||||
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
|
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
|
||||||
{ type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
|
{ type: "text", label: "URL", name: "url" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (r == null) throw new Error("Cancelled remote prompt");
|
if (r == null) throw new Error("Cancelled remote prompt");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Appearance } from "../lib/theme/appearance";
|
import type { Appearance } from "@yaakapp-internal/theme";
|
||||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "../lib/theme/appearance";
|
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
|
||||||
|
|
||||||
export function usePreferredAppearance() {
|
export function usePreferredAppearance() {
|
||||||
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
|
import { resolveAppearance } from "@yaakapp-internal/theme";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { resolveAppearance } from "../lib/theme/appearance";
|
|
||||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||||
|
|
||||||
export function useResolvedAppearance() {
|
export function useResolvedAppearance() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { getResolvedTheme, getThemes } from "../lib/theme/themes";
|
import { getResolvedTheme, getThemes } from "../lib/themes";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
import { usePreferredAppearance } from "./usePreferredAppearance";
|
import { usePreferredAppearance } from "./usePreferredAppearance";
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,32 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { getModel } from "@yaakapp-internal/models";
|
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
import { getActiveCookieJar } from "./useActiveCookieJar";
|
||||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
import { getActiveEnvironment } from "./useActiveEnvironment";
|
||||||
import { createFastMutation, useFastMutation } from "./useFastMutation";
|
import { createFastMutation, useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useSendAnyHttpRequest() {
|
async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse | null> {
|
||||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
if (id == null) {
|
||||||
mutationKey: ["send_any_request"],
|
|
||||||
mutationFn: async (id) => {
|
|
||||||
const request = getModel("http_request", id ?? "n/a");
|
|
||||||
if (request == null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await flushAllModelWrites();
|
||||||
|
|
||||||
return invokeCmd("cmd_send_http_request", {
|
return invokeCmd("cmd_send_http_request", {
|
||||||
request,
|
requestId: id,
|
||||||
environmentId: getActiveEnvironment()?.id,
|
environmentId: getActiveEnvironment()?.id,
|
||||||
cookieJarId: getActiveCookieJar()?.id,
|
cookieJarId: getActiveCookieJar()?.id,
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
|
|
||||||
|
export function useSendAnyHttpRequest() {
|
||||||
|
return useFastMutation<HttpResponse | null, string, string | null>({
|
||||||
|
mutationKey: ["send_any_request"],
|
||||||
|
mutationFn: sendAnyHttpRequestById,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
||||||
mutationKey: ["send_any_request"],
|
mutationKey: ["send_any_request"],
|
||||||
mutationFn: async (id) => {
|
mutationFn: sendAnyHttpRequestById,
|
||||||
const request = getModel("http_request", id ?? "n/a");
|
|
||||||
if (request == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return invokeCmd("cmd_send_http_request", {
|
|
||||||
request,
|
|
||||||
environmentId: getActiveEnvironment()?.id,
|
|
||||||
cookieJarId: getActiveCookieJar()?.id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ export function initGlobalListeners() {
|
|||||||
color: "danger",
|
color: "danger",
|
||||||
timeout: null,
|
timeout: null,
|
||||||
message: `Failed to load plugin "${name}": ${err}`,
|
message: `Failed to load plugin "${name}": ${err}`,
|
||||||
|
action: ({ hide }) => (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="danger"
|
||||||
|
variant="border"
|
||||||
|
onClick={() => {
|
||||||
|
hide();
|
||||||
|
openSettings.mutate("plugins:installed");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage Plugins
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
export function pricingUrl(intent: string): string {
|
|
||||||
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export type { Appearance } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
getCSSAppearance,
|
|
||||||
getWindowAppearance,
|
|
||||||
resolveAppearance,
|
|
||||||
subscribeToPreferredAppearance,
|
|
||||||
subscribeToWindowAppearanceChange,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type { YaakColorKey, YaakColors, YaakTheme } from "@yaakapp-internal/theme";
|
|
||||||
export {
|
|
||||||
addThemeStylesToDocument,
|
|
||||||
applyThemeToDocument,
|
|
||||||
completeTheme,
|
|
||||||
getThemeCSS,
|
|
||||||
indent,
|
|
||||||
setThemeOnDocument,
|
|
||||||
} from "@yaakapp-internal/theme";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { YaakColor } from "@yaakapp-internal/theme";
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
import type { GetThemesResponse } from "@yaakapp-internal/plugins";
|
||||||
import { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
import {
|
||||||
import { invokeCmd } from "../tauri";
|
defaultDarkTheme,
|
||||||
import type { Appearance } from "./appearance";
|
defaultLightTheme,
|
||||||
import { resolveAppearance } from "./appearance";
|
resolveAppearance,
|
||||||
|
type Appearance,
|
||||||
export { defaultDarkTheme, defaultLightTheme } from "@yaakapp-internal/theme";
|
} from "@yaakapp-internal/theme";
|
||||||
|
import { invokeCmd } from "./tauri";
|
||||||
|
|
||||||
export async function getThemes() {
|
export async function getThemes() {
|
||||||
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
const themes = (await invokeCmd<GetThemesResponse[]>("cmd_get_themes")).flatMap((t) => t.themes);
|
||||||
@@ -2,11 +2,14 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
import { setWindowTheme } from "@yaakapp-internal/mac-window";
|
||||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||||
|
import type { Appearance } from "@yaakapp-internal/theme";
|
||||||
|
import {
|
||||||
|
applyThemeToDocument,
|
||||||
|
getCSSAppearance,
|
||||||
|
subscribeToPreferredAppearance,
|
||||||
|
} from "@yaakapp-internal/theme";
|
||||||
import { getSettings } from "./lib/settings";
|
import { getSettings } from "./lib/settings";
|
||||||
import type { Appearance } from "./lib/theme/appearance";
|
import { getResolvedTheme } from "./lib/themes";
|
||||||
import { getCSSAppearance, subscribeToPreferredAppearance } from "./lib/theme/appearance";
|
|
||||||
import { getResolvedTheme } from "./lib/theme/themes";
|
|
||||||
import { applyThemeToDocument } from "@yaakapp-internal/theme";
|
|
||||||
|
|
||||||
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
// NOTE: CSS appearance isn't as accurate as getting it async from the window (next step), but we want
|
||||||
// a good appearance guess so we're not waiting too long
|
// a good appearance guess so we're not waiting too long
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ webbrowser = "1"
|
|||||||
zip = "4"
|
zip = "4"
|
||||||
yaak = { workspace = true }
|
yaak = { workspace = true }
|
||||||
yaak-api = { workspace = true }
|
yaak-api = { workspace = true }
|
||||||
|
yaak-core = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ pub enum Commands {
|
|||||||
/// Authentication commands
|
/// Authentication commands
|
||||||
Auth(AuthArgs),
|
Auth(AuthArgs),
|
||||||
|
|
||||||
|
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
|
||||||
|
Import(ImportArgs),
|
||||||
|
|
||||||
|
/// Export Yaak workspace data
|
||||||
|
Export(ExportArgs),
|
||||||
|
|
||||||
/// Plugin development and publishing commands
|
/// Plugin development and publishing commands
|
||||||
Plugin(PluginArgs),
|
Plugin(PluginArgs),
|
||||||
|
|
||||||
@@ -92,6 +98,34 @@ pub struct SendArgs {
|
|||||||
pub fail_fast: bool,
|
pub fail_fast: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ImportArgs {
|
||||||
|
/// Path to the file to import
|
||||||
|
pub file: PathBuf,
|
||||||
|
|
||||||
|
/// Existing workspace ID to import into when supported by the importer
|
||||||
|
#[arg(long = "workspace-id", value_name = "WORKSPACE_ID")]
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
pub struct ExportArgs {
|
||||||
|
/// Path to write the Yaak export JSON file
|
||||||
|
pub file: PathBuf,
|
||||||
|
|
||||||
|
/// Workspace IDs to export (defaults to the only workspace when exactly one exists)
|
||||||
|
#[arg(value_name = "WORKSPACE_ID")]
|
||||||
|
pub workspace_ids: Vec<String>,
|
||||||
|
|
||||||
|
/// Export all workspaces
|
||||||
|
#[arg(long, conflicts_with = "workspace_ids")]
|
||||||
|
pub all: bool,
|
||||||
|
|
||||||
|
/// Include private environments in the export
|
||||||
|
#[arg(long)]
|
||||||
|
pub include_private_environments: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
#[command(disable_help_subcommand = true)]
|
#[command(disable_help_subcommand = true)]
|
||||||
pub struct CookieJarArgs {
|
pub struct CookieJarArgs {
|
||||||
@@ -447,6 +481,10 @@ pub enum PluginCommands {
|
|||||||
/// Install a plugin from a local directory or from the registry
|
/// Install a plugin from a local directory or from the registry
|
||||||
Install(InstallPluginArgs),
|
Install(InstallPluginArgs),
|
||||||
|
|
||||||
|
/// Generate plugin metadata for the registry
|
||||||
|
#[command(hide = true)]
|
||||||
|
Metadata(PluginPathArg),
|
||||||
|
|
||||||
/// Publish a Yaak plugin version to the plugin registry
|
/// Publish a Yaak plugin version to the plugin registry
|
||||||
Publish(PluginPathArg),
|
Publish(PluginPathArg),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
use crate::cli::{ExportArgs, ImportArgs};
|
||||||
|
use crate::context::CliContext;
|
||||||
|
use crate::utils::workspace::resolve_workspace_id;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use yaak::export::{self, ExportDataParams};
|
||||||
|
use yaak::import;
|
||||||
|
use yaak_core::WorkspaceContext;
|
||||||
|
use yaak_models::util::BatchUpsertResult;
|
||||||
|
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||||
|
|
||||||
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
|
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||||
|
match import(ctx, args).await {
|
||||||
|
Ok(result) => {
|
||||||
|
println!("Imported {}", format_counts(&result));
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Error: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
||||||
|
match export(ctx, args) {
|
||||||
|
Ok(count) => {
|
||||||
|
println!("Exported {count} workspace(s)");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("Error: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
|
||||||
|
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||||
|
ctx.db()
|
||||||
|
.get_workspace(workspace_id)
|
||||||
|
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_contents = read_import_file(&args.file)?;
|
||||||
|
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
||||||
|
let plugin_manager = ctx.plugin_manager();
|
||||||
|
let import_result = plugin_manager
|
||||||
|
.import_data(&plugin_context, &file_contents)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
|
let resources = import_result.resources;
|
||||||
|
let workspace_id = args.workspace_id;
|
||||||
|
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||||
|
return Err(
|
||||||
|
"This import requires a workspace context. Provide --workspace-id <WORKSPACE_ID>."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let workspace_context = WorkspaceContext {
|
||||||
|
workspace_id,
|
||||||
|
environment_id: None,
|
||||||
|
cookie_jar_id: None,
|
||||||
|
request_id: None,
|
||||||
|
};
|
||||||
|
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||||
|
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||||
|
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
|
||||||
|
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
|
||||||
|
export::export_data(ExportDataParams {
|
||||||
|
query_manager: ctx.query_manager(),
|
||||||
|
yaak_version: env!("CARGO_PKG_VERSION"),
|
||||||
|
export_path: &args.file,
|
||||||
|
workspace_ids: workspace_id_refs,
|
||||||
|
include_private_environments: args.include_private_environments,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Failed to export data: {e}"))?;
|
||||||
|
|
||||||
|
Ok(workspace_ids.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_export_workspace_ids(
|
||||||
|
ctx: &CliContext,
|
||||||
|
workspace_ids: Vec<String>,
|
||||||
|
all: bool,
|
||||||
|
) -> CommandResult<Vec<String>> {
|
||||||
|
if all {
|
||||||
|
let workspaces =
|
||||||
|
ctx.db().list_workspaces().map_err(|e| format!("Failed to list workspaces: {e}"))?;
|
||||||
|
if workspaces.is_empty() {
|
||||||
|
return Err("No workspaces found to export".to_string());
|
||||||
|
}
|
||||||
|
return Ok(workspaces.into_iter().map(|w| w.id).collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspace_ids.is_empty() {
|
||||||
|
return resolve_workspace_id(ctx, None, "export").map(|id| vec![id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
for workspace_id in &workspace_ids {
|
||||||
|
ctx.db()
|
||||||
|
.get_workspace(workspace_id)
|
||||||
|
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(workspace_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
|
||||||
|
fs::read_to_string(path).map_err(|err| {
|
||||||
|
if err.kind() == ErrorKind::InvalidData {
|
||||||
|
format!(
|
||||||
|
"Import file must be UTF-8 text; binary files are not supported: {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Unable to read import file {}: {err}", path.display())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
||||||
|
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
||||||
|
|| resources.environments.iter().any(|e| {
|
||||||
|
e.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| e.parent_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.folders.iter().any(|f| {
|
||||||
|
f.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| f.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.http_requests.iter().any(|r| {
|
||||||
|
r.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.grpc_requests.iter().any(|r| {
|
||||||
|
r.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
|| resources.websocket_requests.iter().any(|r| {
|
||||||
|
r.workspace_id == "CURRENT_WORKSPACE"
|
||||||
|
|| r.folder_id.as_deref() == Some("CURRENT_WORKSPACE")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_counts(result: &BatchUpsertResult) -> String {
|
||||||
|
let names = [
|
||||||
|
"workspace",
|
||||||
|
"environment",
|
||||||
|
"folder",
|
||||||
|
"HTTP request",
|
||||||
|
"gRPC request",
|
||||||
|
"WebSocket request",
|
||||||
|
];
|
||||||
|
let counts = [
|
||||||
|
(result.workspaces.len(), names[0]),
|
||||||
|
(result.environments.len(), names[1]),
|
||||||
|
(result.folders.len(), names[2]),
|
||||||
|
(result.http_requests.len(), names[3]),
|
||||||
|
(result.grpc_requests.len(), names[4]),
|
||||||
|
(result.websocket_requests.len(), names[5]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let non_zero: Vec<String> = counts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(count, _)| *count > 0)
|
||||||
|
.map(|(count, name)| format!("{count} {name}{}", if count == 1 { "" } else { "s" }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if non_zero.is_empty() { "nothing".to_string() } else { non_zero.join(", ") }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ pub mod auth;
|
|||||||
pub mod cookie_jar;
|
pub mod cookie_jar;
|
||||||
pub mod environment;
|
pub mod environment;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
|
pub mod import_export;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod request;
|
pub mod request;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use std::collections::HashSet;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, IsTerminal, Read, Write};
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
@@ -27,6 +28,11 @@ use zip::write::SimpleFileOptions;
|
|||||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||||
|
|
||||||
const KEYRING_USER: &str = "yaak";
|
const KEYRING_USER: &str = "yaak";
|
||||||
|
const METADATA_NODE_BIN: &str = "node";
|
||||||
|
const PLUGIN_RUNTIME_NODE_VERSION: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../../packages/plugin-runtime/.node-version"
|
||||||
|
));
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
enum Environment {
|
enum Environment {
|
||||||
@@ -103,6 +109,16 @@ pub async fn run_publish(args: PluginPathArg) -> i32 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_metadata(args: PluginPathArg) -> i32 {
|
||||||
|
match metadata(args) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
ui::error(&error);
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn build(args: PluginPathArg) -> CommandResult {
|
async fn build(args: PluginPathArg) -> CommandResult {
|
||||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||||
@@ -112,10 +128,21 @@ async fn build(args: PluginPathArg) -> CommandResult {
|
|||||||
for warning in warnings {
|
for warning in warnings {
|
||||||
ui::warning(&warning);
|
ui::warning(&warning);
|
||||||
}
|
}
|
||||||
|
generate_plugin_metadata(&plugin_dir)?;
|
||||||
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
|
ui::success(&format!("Built plugin bundle at {}", plugin_dir.join("build/index.js").display()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn metadata(args: PluginPathArg) -> CommandResult {
|
||||||
|
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||||
|
generate_plugin_metadata(&plugin_dir)?;
|
||||||
|
ui::success(&format!(
|
||||||
|
"Generated plugin metadata at {}",
|
||||||
|
plugin_dir.join("build/metadata.json").display()
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn dev(args: PluginPathArg) -> CommandResult {
|
async fn dev(args: PluginPathArg) -> CommandResult {
|
||||||
let plugin_dir = resolve_plugin_dir(args.path)?;
|
let plugin_dir = resolve_plugin_dir(args.path)?;
|
||||||
ensure_plugin_build_inputs(&plugin_dir)?;
|
ensure_plugin_build_inputs(&plugin_dir)?;
|
||||||
@@ -153,7 +180,15 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
|||||||
});
|
});
|
||||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||||
}
|
}
|
||||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {}
|
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||||
|
match generate_plugin_metadata(&watch_root) {
|
||||||
|
Ok(()) => ui::success(&format!(
|
||||||
|
"Generated plugin metadata at {}",
|
||||||
|
watch_root.join("build/metadata.json").display()
|
||||||
|
)),
|
||||||
|
Err(error) => ui::error(&error),
|
||||||
|
}
|
||||||
|
}
|
||||||
WatcherEvent::Event(BundleEvent::Error(event)) => {
|
WatcherEvent::Event(BundleEvent::Error(event)) => {
|
||||||
if event.error.diagnostics.is_empty() {
|
if event.error.diagnostics.is_empty() {
|
||||||
ui::error("Plugin build failed");
|
ui::error("Plugin build failed");
|
||||||
@@ -228,6 +263,7 @@ async fn publish(args: PluginPathArg) -> CommandResult {
|
|||||||
for warning in warnings {
|
for warning in warnings {
|
||||||
ui::warning(&warning);
|
ui::warning(&warning);
|
||||||
}
|
}
|
||||||
|
generate_plugin_metadata(&plugin_dir)?;
|
||||||
|
|
||||||
ui::info("Archiving plugin");
|
ui::info("Archiving plugin");
|
||||||
let archive = create_publish_archive(&plugin_dir)?;
|
let archive = create_publish_archive(&plugin_dir)?;
|
||||||
@@ -379,6 +415,79 @@ async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
|||||||
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
|
Ok(output.warnings.into_iter().map(|w| w.to_string()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generate_plugin_metadata(plugin_dir: &Path) -> CommandResult {
|
||||||
|
let entry_path = plugin_dir.join("build/index.js");
|
||||||
|
if !entry_path.is_file() {
|
||||||
|
return Err("build/index.js does not exist. Run `yaak plugin build` first.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_metadata_node_version()?;
|
||||||
|
|
||||||
|
let metadata_path = plugin_dir.join("build/metadata.json");
|
||||||
|
let output = Command::new(METADATA_NODE_BIN)
|
||||||
|
.arg("-e")
|
||||||
|
.arg(METADATA_SCRIPT)
|
||||||
|
.arg(entry_path.canonicalize().map_err(|e| {
|
||||||
|
format!("Failed to resolve plugin entrypoint {}: {e}", entry_path.display())
|
||||||
|
})?)
|
||||||
|
.arg(&metadata_path)
|
||||||
|
.current_dir(plugin_dir)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run Node.js to generate plugin metadata: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let message = if stderr.is_empty() {
|
||||||
|
format!("Node.js exited with status {}", output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
};
|
||||||
|
return Err(format!("Failed to generate plugin metadata: {message}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_metadata_node_version() -> CommandResult {
|
||||||
|
let minimum_major = PLUGIN_RUNTIME_NODE_VERSION
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('v')
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.and_then(|part| part.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"Invalid plugin runtime Node.js version {:?} in packages/plugin-runtime/.node-version",
|
||||||
|
PLUGIN_RUNTIME_NODE_VERSION.trim()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let output = Command::new(METADATA_NODE_BIN)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Node.js {minimum_major} or newer is required: {e}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"`{METADATA_NODE_BIN} --version` failed with status {}",
|
||||||
|
output.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let major = version
|
||||||
|
.trim_start_matches('v')
|
||||||
|
.split('.')
|
||||||
|
.next()
|
||||||
|
.and_then(|part| part.parse::<u32>().ok())
|
||||||
|
.ok_or_else(|| format!("Could not parse Node.js version {version:?}"))?;
|
||||||
|
|
||||||
|
if major >= minimum_major {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Node.js {minimum_major} or newer is required. Found {version}."))
|
||||||
|
}
|
||||||
|
|
||||||
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||||
let build_dir = plugin_dir.join("build");
|
let build_dir = plugin_dir.join("build");
|
||||||
if build_dir.exists() {
|
if build_dir.exists() {
|
||||||
@@ -578,6 +687,11 @@ const TEMPLATE_PACKAGE_JSON: &str = r#"{
|
|||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const METADATA_SCRIPT: &str = include_str!(concat!(
|
||||||
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
|
"/../../packages/plugin-runtime/src/metadata.ts"
|
||||||
|
));
|
||||||
|
|
||||||
const TEMPLATE_TSCONFIG: &str = r#"{
|
const TEMPLATE_TSCONFIG: &str = r#"{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2021",
|
"target": "es2021",
|
||||||
@@ -636,7 +750,8 @@ describe("Example Plugin", () => {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::create_publish_archive;
|
use super::{create_publish_archive, generate_plugin_metadata};
|
||||||
|
use serde_json::Value;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
@@ -659,6 +774,7 @@ mod tests {
|
|||||||
.expect("write src/index.ts");
|
.expect("write src/index.ts");
|
||||||
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
|
fs::write(root.join("build/index.js"), "exports.plugin = {};\n")
|
||||||
.expect("write build/index.js");
|
.expect("write build/index.js");
|
||||||
|
fs::write(root.join("build/metadata.json"), "{}\n").expect("write build/metadata.json");
|
||||||
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
|
fs::write(root.join("ignored/secret.txt"), "do-not-ship").expect("write ignored file");
|
||||||
|
|
||||||
let archive = create_publish_archive(root).expect("create archive");
|
let archive = create_publish_archive(root).expect("create archive");
|
||||||
@@ -673,8 +789,74 @@ mod tests {
|
|||||||
assert!(names.contains("README.md"));
|
assert!(names.contains("README.md"));
|
||||||
assert!(names.contains("package.json"));
|
assert!(names.contains("package.json"));
|
||||||
assert!(names.contains("package-lock.json"));
|
assert!(names.contains("package-lock.json"));
|
||||||
|
assert!(names.contains("build/metadata.json"));
|
||||||
assert!(names.contains("src/index.ts"));
|
assert!(names.contains("src/index.ts"));
|
||||||
assert!(names.contains("build/index.js"));
|
assert!(names.contains("build/index.js"));
|
||||||
assert!(!names.contains("ignored/secret.txt"));
|
assert!(!names.contains("ignored/secret.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_plugin_metadata_detects_api_types() {
|
||||||
|
let dir = TempDir::new().expect("temp dir");
|
||||||
|
let root = dir.path();
|
||||||
|
fs::create_dir_all(root.join("build")).expect("create build");
|
||||||
|
fs::write(
|
||||||
|
root.join("build/index.js"),
|
||||||
|
r##"
|
||||||
|
exports.plugin = {
|
||||||
|
themes: [{
|
||||||
|
id: "midnight",
|
||||||
|
label: "Midnight",
|
||||||
|
dark: true,
|
||||||
|
base: { surface: "#000000", text: "#ffffff" },
|
||||||
|
}],
|
||||||
|
templateFunctions: [{
|
||||||
|
name: "signature",
|
||||||
|
description: "Create a signature",
|
||||||
|
args: [{ type: "text", name: "secret", dynamic() {} }],
|
||||||
|
onRender() {},
|
||||||
|
}],
|
||||||
|
workspaceActions: [{
|
||||||
|
label: "Sync workspace",
|
||||||
|
icon: "info",
|
||||||
|
onSelect() {},
|
||||||
|
}],
|
||||||
|
folderActions: [{
|
||||||
|
label: "Export folder",
|
||||||
|
icon: "copy",
|
||||||
|
onSelect() {},
|
||||||
|
}],
|
||||||
|
async init() {},
|
||||||
|
};
|
||||||
|
"##,
|
||||||
|
)
|
||||||
|
.expect("write build/index.js");
|
||||||
|
|
||||||
|
generate_plugin_metadata(root).expect("generate metadata");
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(root.join("build/metadata.json")).expect("read metadata");
|
||||||
|
let metadata: Value = serde_json::from_str(&contents).expect("metadata json");
|
||||||
|
let api_types = metadata["apiTypes"].as_array().expect("apiTypes array");
|
||||||
|
|
||||||
|
for expected in [
|
||||||
|
"folderActions",
|
||||||
|
"templateFunctions",
|
||||||
|
"themes",
|
||||||
|
"workspaceActions",
|
||||||
|
"lifecycle",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
api_types.iter().any(|value| value.as_str() == Some(expected)),
|
||||||
|
"missing api type {expected}: {api_types:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(metadata["apis"]["themes"]["items"][0]["id"], "midnight");
|
||||||
|
assert_eq!(metadata["apis"]["workspaceActions"]["items"][0]["label"], "Sync workspace");
|
||||||
|
assert_eq!(metadata["apis"]["lifecycle"]["items"][0]["name"], "init");
|
||||||
|
assert!(metadata["apis"]["templateFunctions"]["items"][0]["onRender"].is_null());
|
||||||
|
assert!(
|
||||||
|
metadata["apis"]["templateFunctions"]["items"][0]["args"][0]["dynamic"].is_null()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,29 @@ async fn main() {
|
|||||||
|
|
||||||
let exit_code = match command {
|
let exit_code = match command {
|
||||||
Commands::Auth(args) => commands::auth::run(args).await,
|
Commands::Auth(args) => commands::auth::run(args).await,
|
||||||
|
Commands::Import(args) => {
|
||||||
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
|
let execution_context = CliExecutionContext {
|
||||||
|
workspace_id: args.workspace_id.clone(),
|
||||||
|
..CliExecutionContext::default()
|
||||||
|
};
|
||||||
|
context.init_plugins(execution_context).await;
|
||||||
|
let exit_code = commands::import_export::run_import(&context, args).await;
|
||||||
|
context.shutdown().await;
|
||||||
|
exit_code
|
||||||
|
}
|
||||||
|
Commands::Export(args) => {
|
||||||
|
let context = CliContext::new(data_dir.clone(), app_id);
|
||||||
|
let exit_code = commands::import_export::run_export(&context, args);
|
||||||
|
context.shutdown().await;
|
||||||
|
exit_code
|
||||||
|
}
|
||||||
Commands::Plugin(args) => match args.command {
|
Commands::Plugin(args) => match args.command {
|
||||||
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
|
PluginCommands::Build(args) => commands::plugin::run_build(args).await,
|
||||||
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
|
PluginCommands::Dev(args) => commands::plugin::run_dev(args).await,
|
||||||
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
|
PluginCommands::Generate(args) => commands::plugin::run_generate(args).await,
|
||||||
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
|
PluginCommands::Publish(args) => commands::plugin::run_publish(args).await,
|
||||||
|
PluginCommands::Metadata(args) => commands::plugin::run_metadata(args).await,
|
||||||
PluginCommands::Install(install_args) => {
|
PluginCommands::Install(install_args) => {
|
||||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||||
context.init_plugins(CliExecutionContext::default()).await;
|
context.init_plugins(CliExecutionContext::default()).await;
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||||
|
use predicates::str::contains;
|
||||||
|
use serde_json::Value;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_writes_yaak_workspace_file() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let export_path = temp_dir.path().join("export.json");
|
||||||
|
|
||||||
|
let create_assert =
|
||||||
|
cli_cmd(data_dir).args(["workspace", "create", "--name", "Export Me"]).assert().success();
|
||||||
|
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
|
||||||
|
seed_request(data_dir, &workspace_id, "req_export");
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"export",
|
||||||
|
export_path.to_str().expect("export path is utf-8"),
|
||||||
|
&workspace_id,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("Exported 1 workspace(s)"));
|
||||||
|
|
||||||
|
let exported: Value = serde_json::from_str(
|
||||||
|
&std::fs::read_to_string(export_path).expect("export file should exist"),
|
||||||
|
)
|
||||||
|
.expect("export should be JSON");
|
||||||
|
|
||||||
|
assert_eq!(exported["yaakSchema"], 4);
|
||||||
|
assert_eq!(exported["resources"]["workspaces"][0]["id"], workspace_id);
|
||||||
|
assert_eq!(exported["resources"]["httpRequests"][0]["id"], "req_export");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_reads_yaak_workspace_file() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let import_path = temp_dir.path().join("import.json");
|
||||||
|
|
||||||
|
std::fs::write(
|
||||||
|
&import_path,
|
||||||
|
r#"{
|
||||||
|
"yaakVersion": "test",
|
||||||
|
"yaakSchema": 4,
|
||||||
|
"resources": {
|
||||||
|
"workspaces": [
|
||||||
|
{
|
||||||
|
"model": "workspace",
|
||||||
|
"id": "wrk_import",
|
||||||
|
"name": "Imported Workspace"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"httpRequests": [
|
||||||
|
{
|
||||||
|
"model": "http_request",
|
||||||
|
"id": "req_import",
|
||||||
|
"workspaceId": "wrk_import",
|
||||||
|
"name": "Imported Request",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "https://example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write import fixture");
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"import",
|
||||||
|
import_path.to_str().expect("import path is utf-8"),
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("Imported 1 workspace, 1 HTTP request"));
|
||||||
|
|
||||||
|
let query_manager = query_manager(data_dir);
|
||||||
|
let db = query_manager.connect();
|
||||||
|
assert_eq!(
|
||||||
|
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||||
|
"Imported Workspace"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
db.get_http_request("req_import").expect("request imported").url,
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||||
|
std::fs::write(
|
||||||
|
path,
|
||||||
|
r#"{
|
||||||
|
"name": "Local",
|
||||||
|
"_postman_variable_scope": "environment",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"key": "token",
|
||||||
|
"value": "abc123",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("write postman environment fixture");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_postman_environment_requires_workspace_id() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let import_path = temp_dir.path().join("postman-env.json");
|
||||||
|
|
||||||
|
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
|
||||||
|
write_postman_environment_fixture(&import_path);
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"import",
|
||||||
|
import_path.to_str().expect("import path is utf-8"),
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(contains("requires a workspace context"))
|
||||||
|
.stderr(contains("--workspace-id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_postman_environment_uses_workspace_id() {
|
||||||
|
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||||
|
let data_dir = temp_dir.path();
|
||||||
|
let import_path = temp_dir.path().join("postman-env.json");
|
||||||
|
|
||||||
|
let create_assert =
|
||||||
|
cli_cmd(data_dir).args(["workspace", "create", "--name", "Env Target"]).assert().success();
|
||||||
|
let workspace_id = parse_created_id(&create_assert.get_output().stdout, "workspace create");
|
||||||
|
write_postman_environment_fixture(&import_path);
|
||||||
|
|
||||||
|
cli_cmd(data_dir)
|
||||||
|
.args([
|
||||||
|
"import",
|
||||||
|
import_path.to_str().expect("import path is utf-8"),
|
||||||
|
"--workspace-id",
|
||||||
|
&workspace_id,
|
||||||
|
])
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(contains("Imported 1 environment"));
|
||||||
|
|
||||||
|
let query_manager = query_manager(data_dir);
|
||||||
|
let db = query_manager.connect();
|
||||||
|
let environments =
|
||||||
|
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
|
||||||
|
|
||||||
|
let imported_environment =
|
||||||
|
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||||
|
assert_eq!(imported_environment.workspace_id, workspace_id);
|
||||||
|
}
|
||||||
@@ -38,6 +38,9 @@ pub enum Error {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
ApiError(#[from] yaak_api::Error),
|
ApiError(#[from] yaak_api::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
YaakError(#[from] yaak::Error),
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
use crate::PluginContextExt;
|
use crate::PluginContextExt;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::models_ext::QueryManagerExt;
|
use crate::models_ext::QueryManagerExt;
|
||||||
use log::info;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::fs::read_to_string;
|
use std::fs::read_to_string;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
use tauri::{Manager, Runtime, WebviewWindow};
|
use tauri::{Manager, Runtime, WebviewWindow};
|
||||||
|
use yaak::import::{self, ImportDataParams};
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_models::models::{
|
use yaak_models::util::BatchUpsertResult;
|
||||||
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
|
||||||
};
|
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
|
|
||||||
@@ -19,113 +15,24 @@ pub(crate) async fn import_data<R: Runtime>(
|
|||||||
file_path: &str,
|
file_path: &str,
|
||||||
) -> Result<BatchUpsertResult> {
|
) -> Result<BatchUpsertResult> {
|
||||||
let plugin_manager = window.state::<PluginManager>();
|
let plugin_manager = window.state::<PluginManager>();
|
||||||
|
let query_manager = window.db_manager();
|
||||||
let file = read_import_file(file_path)?;
|
let file = read_import_file(file_path)?;
|
||||||
let file_contents = file.as_str();
|
let plugin_context = window.plugin_context();
|
||||||
let import_result = plugin_manager.import_data(&window.plugin_context(), file_contents).await?;
|
let workspace_context = WorkspaceContext {
|
||||||
|
|
||||||
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
|
||||||
|
|
||||||
// Create WorkspaceContext from window
|
|
||||||
let ctx = WorkspaceContext {
|
|
||||||
workspace_id: window.workspace_id(),
|
workspace_id: window.workspace_id(),
|
||||||
environment_id: window.environment_id(),
|
environment_id: window.environment_id(),
|
||||||
cookie_jar_id: window.cookie_jar_id(),
|
cookie_jar_id: window.cookie_jar_id(),
|
||||||
request_id: None,
|
request_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let resources = import_result.resources;
|
Ok(import::import_data(ImportDataParams {
|
||||||
|
query_manager: &query_manager,
|
||||||
let workspaces: Vec<Workspace> = resources
|
plugin_manager: &plugin_manager,
|
||||||
.workspaces
|
plugin_context: &plugin_context,
|
||||||
.into_iter()
|
workspace_context,
|
||||||
.map(|mut v| {
|
contents: &file,
|
||||||
v.id = maybe_gen_id::<Workspace>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
})
|
||||||
.collect();
|
.await?)
|
||||||
|
|
||||||
let environments: Vec<Environment> = resources
|
|
||||||
.environments
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<Environment>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
|
||||||
("folder", Some(parent_id)) => {
|
|
||||||
v.parent_id = Some(maybe_gen_id::<Folder>(&ctx, &parent_id, &mut id_map));
|
|
||||||
}
|
|
||||||
("", _) => {
|
|
||||||
// Fix any empty ones
|
|
||||||
v.parent_model = "workspace".to_string();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Parent ID only required for the folder case
|
|
||||||
v.parent_id = None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let folders: Vec<Folder> = resources
|
|
||||||
.folders
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<Folder>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let http_requests: Vec<HttpRequest> = resources
|
|
||||||
.http_requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<HttpRequest>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let grpc_requests: Vec<GrpcRequest> = resources
|
|
||||||
.grpc_requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<GrpcRequest>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let websocket_requests: Vec<WebsocketRequest> = resources
|
|
||||||
.websocket_requests
|
|
||||||
.into_iter()
|
|
||||||
.map(|mut v| {
|
|
||||||
v.id = maybe_gen_id::<WebsocketRequest>(&ctx, v.id.as_str(), &mut id_map);
|
|
||||||
v.workspace_id = maybe_gen_id::<Workspace>(&ctx, v.workspace_id.as_str(), &mut id_map);
|
|
||||||
v.folder_id = maybe_gen_id_opt::<Folder>(&ctx, v.folder_id, &mut id_map);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
info!("Importing data");
|
|
||||||
|
|
||||||
let upserted = window.with_tx(|tx| {
|
|
||||||
tx.batch_upsert(
|
|
||||||
workspaces,
|
|
||||||
environments,
|
|
||||||
folders,
|
|
||||||
http_requests,
|
|
||||||
grpc_requests,
|
|
||||||
websocket_requests,
|
|
||||||
&UpdateSource::Import,
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(upserted)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_import_file(file_path: &str) -> Result<String> {
|
fn read_import_file(file_path: &str) -> Result<String> {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ use error::Result as YaakResult;
|
|||||||
use eventsource_client::{EventParser, SSE};
|
use eventsource_client::{EventParser, SSE};
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::path::{Path, PathBuf};
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -31,6 +30,7 @@ use tauri_plugin_window_state::{AppHandleExt, StateFlags};
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tokio::task::block_in_place;
|
use tokio::task::block_in_place;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
use yaak::export::{self, ExportDataParams};
|
||||||
use yaak_common::command::new_checked_command;
|
use yaak_common::command::new_checked_command;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||||
@@ -41,7 +41,7 @@ use yaak_models::models::{
|
|||||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
GrpcEventType, HttpRequest, HttpResponse, HttpResponseEvent, HttpResponseState, Workspace,
|
||||||
WorkspaceMeta,
|
WorkspaceMeta,
|
||||||
};
|
};
|
||||||
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
|
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||||
use yaak_plugins::events::{
|
use yaak_plugins::events::{
|
||||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||||
@@ -54,7 +54,7 @@ use yaak_plugins::events::{
|
|||||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||||
};
|
};
|
||||||
use yaak_plugins::manager::PluginManager;
|
use yaak_plugins::manager::PluginManager;
|
||||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
use yaak_plugins::plugin_meta::{PluginMetadata, get_plugin_meta};
|
||||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||||
use yaak_sse::sse::ServerSentEvent;
|
use yaak_sse::sse::ServerSentEvent;
|
||||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||||
@@ -1384,24 +1384,14 @@ async fn cmd_export_data<R: Runtime>(
|
|||||||
workspace_ids: Vec<&str>,
|
workspace_ids: Vec<&str>,
|
||||||
include_private_environments: bool,
|
include_private_environments: bool,
|
||||||
) -> YaakResult<()> {
|
) -> YaakResult<()> {
|
||||||
let db = app_handle.db();
|
|
||||||
let version = app_handle.package_info().version.to_string();
|
let version = app_handle.package_info().version.to_string();
|
||||||
let export_data =
|
Ok(export::export_data(ExportDataParams {
|
||||||
get_workspace_export_resources(&db, &version, workspace_ids, include_private_environments)?;
|
query_manager: &app_handle.db_manager(),
|
||||||
let f = File::options()
|
yaak_version: &version,
|
||||||
.create(true)
|
export_path: Path::new(export_path),
|
||||||
.truncate(true)
|
workspace_ids,
|
||||||
.write(true)
|
include_private_environments,
|
||||||
.open(export_path)
|
})?)
|
||||||
.expect("Unable to create file");
|
|
||||||
|
|
||||||
serde_json::to_writer_pretty(&f, &export_data)
|
|
||||||
.map_err(|e| GenericError(e.to_string()))
|
|
||||||
.expect("Failed to write");
|
|
||||||
|
|
||||||
f.sync_all().expect("Failed to sync");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1425,11 +1415,10 @@ async fn cmd_send_http_request<R: Runtime>(
|
|||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
environment_id: Option<&str>,
|
environment_id: Option<&str>,
|
||||||
cookie_jar_id: Option<&str>,
|
cookie_jar_id: Option<&str>,
|
||||||
// NOTE: We receive the entire request because to account for the race
|
request_id: String,
|
||||||
// condition where the user may have just edited a field before sending
|
|
||||||
// that has not yet been saved in the DB.
|
|
||||||
request: HttpRequest,
|
|
||||||
) -> YaakResult<HttpResponse> {
|
) -> YaakResult<HttpResponse> {
|
||||||
|
let request = app_handle.db().get_http_request(&request_id)?;
|
||||||
|
|
||||||
let blobs = app_handle.blob_manager();
|
let blobs = app_handle.blob_manager();
|
||||||
let response = app_handle.db().upsert_http_response(
|
let response = app_handle.db().upsert_http_response(
|
||||||
&HttpResponse {
|
&HttpResponse {
|
||||||
@@ -1512,11 +1501,36 @@ async fn cmd_plugin_info<R: Runtime>(
|
|||||||
plugin_manager: State<'_, PluginManager>,
|
plugin_manager: State<'_, PluginManager>,
|
||||||
) -> YaakResult<PluginMetadata> {
|
) -> YaakResult<PluginMetadata> {
|
||||||
let plugin = app_handle.db().get_plugin(id)?;
|
let plugin = app_handle.db().get_plugin(id)?;
|
||||||
Ok(plugin_manager
|
if let Some(plugin_handle) = plugin_manager
|
||||||
.get_plugin_by_dir(plugin.directory.as_str())
|
.get_plugin_by_dir(plugin.directory.as_str())
|
||||||
.await
|
.await
|
||||||
.ok_or(GenericError("Failed to find plugin for info".to_string()))?
|
{
|
||||||
.info())
|
return Ok(plugin_handle.info());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(metadata) = get_plugin_meta(&PathBuf::from(&plugin.directory)) {
|
||||||
|
return Ok(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(fallback_plugin_metadata(&plugin.directory))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_plugin_metadata(directory: &str) -> PluginMetadata {
|
||||||
|
let display_name = PathBuf::from(directory)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.unwrap_or(directory)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
PluginMetadata {
|
||||||
|
version: "Unavailable".to_string(),
|
||||||
|
name: directory.to_string(),
|
||||||
|
display_name,
|
||||||
|
description: Some(format!("Plugin metadata could not be loaded from {directory}")),
|
||||||
|
homepage_url: None,
|
||||||
|
repository_url: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { newStoreData } from "./util";
|
|||||||
|
|
||||||
let _store: JotaiStore | null = null;
|
let _store: JotaiStore | null = null;
|
||||||
|
|
||||||
|
const pendingModelWrites = new Set<Promise<unknown>>();
|
||||||
|
|
||||||
export function initModelStore(store: JotaiStore) {
|
export function initModelStore(store: JotaiStore) {
|
||||||
_store = store;
|
_store = store;
|
||||||
|
|
||||||
@@ -42,6 +44,23 @@ function mustStore(): JotaiStore {
|
|||||||
return _store;
|
return _store;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackModelWrite<T>(write: Promise<T>): Promise<T> {
|
||||||
|
const tracked = write.finally(() => {
|
||||||
|
pendingModelWrites.delete(tracked);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingModelWrites.add(tracked);
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function flushAllModelWrites(): Promise<void> {
|
||||||
|
const results = await Promise.allSettled([...pendingModelWrites]);
|
||||||
|
const rejected = results.find((result) => result.status === "rejected");
|
||||||
|
if (rejected?.status === "rejected") {
|
||||||
|
throw rejected.reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _activeWorkspaceId: string | null = null;
|
let _activeWorkspaceId: string | null = null;
|
||||||
|
|
||||||
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
export async function changeModelStoreWorkspace(workspaceId: string | null) {
|
||||||
@@ -117,7 +136,7 @@ export async function patchModel<M extends AnyModel["model"], T extends ExtractM
|
|||||||
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
export async function updateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||||
model: T,
|
model: T,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return invoke<string>("models_upsert", { model });
|
return trackModelWrite(invoke<string>("models_upsert", { model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteModelById<
|
export async function deleteModelById<
|
||||||
@@ -134,7 +153,7 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
|
|||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new Error("Failed to delete null model");
|
throw new Error("Failed to delete null model");
|
||||||
}
|
}
|
||||||
await invoke<string>("models_delete", { model });
|
await trackModelWrite(invoke<string>("models_delete", { model }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||||
@@ -174,19 +193,19 @@ export function duplicateModel<M extends AnyModel["model"], T extends ExtractMod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return invoke<string>("models_duplicate", { model: { ...model, name } });
|
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
||||||
patch: Partial<T> & Pick<T, "model">,
|
patch: Partial<T> & Pick<T, "model">,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return invoke<string>("models_upsert", { model: patch });
|
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
|
export async function createWorkspaceModel<T extends Extract<AnyModel, { workspaceId: string }>>(
|
||||||
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
|
patch: Partial<T> & Pick<T, "model" | "workspaceId">,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return invoke<string>("models_upsert", { model: patch });
|
return trackModelWrite(invoke<string>("models_upsert", { model: patch }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceModelsInStore<
|
export function replaceModelsInStore<
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
|
use crate::api::{PluginVersion, download_plugin_archive, get_plugin};
|
||||||
use crate::checksum::compute_checksum;
|
use crate::checksum::compute_checksum;
|
||||||
use crate::error::Error::PluginErr;
|
use crate::error::Error::{PluginErr, PluginNotFoundErr};
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::events::PluginContext;
|
use crate::events::PluginContext;
|
||||||
use crate::manager::PluginManager;
|
use crate::manager::PluginManager;
|
||||||
@@ -29,7 +29,14 @@ pub async fn delete_and_uninstall(
|
|||||||
let db = query_manager.connect();
|
let db = query_manager.connect();
|
||||||
db.delete_plugin_by_id(plugin_id, &update_source)?
|
db.delete_plugin_by_id(plugin_id, &update_source)?
|
||||||
};
|
};
|
||||||
plugin_manager.uninstall(plugin_context, plugin.directory.as_str()).await?;
|
if let Err(err) = plugin_manager
|
||||||
|
.uninstall(plugin_context, plugin.directory.as_str())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
if !matches!(err, PluginNotFoundErr(_)) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(plugin)
|
Ok(plugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1070,7 +1070,7 @@ impl PluginManager {
|
|||||||
&InternalEventPayload::ImportRequest(ImportRequest {
|
&InternalEventPayload::ImportRequest(ImportRequest {
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
}),
|
}),
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(60),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ serde_json = { workspace = true }
|
|||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||||
yaak-http = { workspace = true }
|
yaak-http = { workspace = true }
|
||||||
|
yaak-core = { workspace = true }
|
||||||
yaak-crypto = { workspace = true }
|
yaak-crypto = { workspace = true }
|
||||||
yaak-models = { workspace = true }
|
yaak-models = { workspace = true }
|
||||||
yaak-plugins = { workspace = true }
|
yaak-plugins = { workspace = true }
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ use thiserror::Error;
|
|||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
Send(#[from] crate::send::SendHttpRequestError),
|
Send(#[from] crate::send::SendHttpRequestError),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Model(#[from] yaak_models::error::Error),
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
Plugin(#[from] yaak_plugins::error::Error),
|
||||||
|
|
||||||
|
#[error("I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
|
#[error("JSON error: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::path::Path;
|
||||||
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::util::get_workspace_export_resources;
|
||||||
|
|
||||||
|
pub struct ExportDataParams<'a> {
|
||||||
|
pub query_manager: &'a QueryManager,
|
||||||
|
pub yaak_version: &'a str,
|
||||||
|
pub export_path: &'a Path,
|
||||||
|
pub workspace_ids: Vec<&'a str>,
|
||||||
|
pub include_private_environments: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn export_data(params: ExportDataParams<'_>) -> Result<()> {
|
||||||
|
let db = params.query_manager.connect();
|
||||||
|
let export_data = get_workspace_export_resources(
|
||||||
|
&db,
|
||||||
|
params.yaak_version,
|
||||||
|
params.workspace_ids,
|
||||||
|
params.include_private_environments,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let file = File::options().create(true).truncate(true).write(true).open(params.export_path)?;
|
||||||
|
serde_json::to_writer_pretty(&file, &export_data)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
use crate::Result;
|
||||||
|
use log::info;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use yaak_core::WorkspaceContext;
|
||||||
|
use yaak_models::models::{
|
||||||
|
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
|
||||||
|
};
|
||||||
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
|
||||||
|
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||||
|
use yaak_plugins::manager::PluginManager;
|
||||||
|
|
||||||
|
pub struct ImportDataParams<'a> {
|
||||||
|
pub query_manager: &'a QueryManager,
|
||||||
|
pub plugin_manager: &'a PluginManager,
|
||||||
|
pub plugin_context: &'a PluginContext,
|
||||||
|
pub workspace_context: WorkspaceContext,
|
||||||
|
pub contents: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
|
||||||
|
let import_result =
|
||||||
|
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
|
||||||
|
|
||||||
|
import_resources(params.query_manager, params.workspace_context, import_result.resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn import_resources(
|
||||||
|
query_manager: &QueryManager,
|
||||||
|
workspace_context: WorkspaceContext,
|
||||||
|
resources: ImportResources,
|
||||||
|
) -> Result<BatchUpsertResult> {
|
||||||
|
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
|
||||||
|
|
||||||
|
let workspaces: Vec<Workspace> = resources
|
||||||
|
.workspaces
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let environments: Vec<Environment> = resources
|
||||||
|
.environments
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||||
|
v.workspace_id =
|
||||||
|
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||||
|
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
|
||||||
|
("folder", Some(parent_id)) => {
|
||||||
|
v.parent_id =
|
||||||
|
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
|
||||||
|
}
|
||||||
|
("", _) => {
|
||||||
|
v.parent_model = "workspace".to_string();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
v.parent_id = None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let folders: Vec<Folder> = resources
|
||||||
|
.folders
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||||
|
v.workspace_id =
|
||||||
|
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||||
|
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let http_requests: Vec<HttpRequest> = resources
|
||||||
|
.http_requests
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||||
|
v.workspace_id =
|
||||||
|
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||||
|
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let grpc_requests: Vec<GrpcRequest> = resources
|
||||||
|
.grpc_requests
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||||
|
v.workspace_id =
|
||||||
|
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||||
|
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let websocket_requests: Vec<WebsocketRequest> = resources
|
||||||
|
.websocket_requests
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut v| {
|
||||||
|
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
|
||||||
|
v.workspace_id =
|
||||||
|
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
|
||||||
|
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
info!("Importing data");
|
||||||
|
|
||||||
|
query_manager.with_tx(|tx| {
|
||||||
|
tx.batch_upsert(
|
||||||
|
workspaces,
|
||||||
|
environments,
|
||||||
|
folders,
|
||||||
|
http_requests,
|
||||||
|
grpc_requests,
|
||||||
|
websocket_requests,
|
||||||
|
&UpdateSource::Import,
|
||||||
|
)
|
||||||
|
.map_err(crate::Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod export;
|
||||||
|
pub mod import;
|
||||||
pub mod plugin_events;
|
pub mod plugin_events;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
|||||||
Generated
+321
-446
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
24.11.1
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"ws": "^8.20.1"
|
"ws": "^8.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.13",
|
||||||
"@types/ws": "^8.5.13"
|
"@types/ws": "^8.5.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { PluginDefinition } from "@yaakapp/api";
|
||||||
|
|
||||||
|
type PluginFeatureKey = Exclude<
|
||||||
|
Extract<keyof PluginDefinition, string>,
|
||||||
|
"init" | "dispose"
|
||||||
|
>;
|
||||||
|
type PluginAPIKey = PluginFeatureKey | "lifecycle";
|
||||||
|
|
||||||
|
type MetadataDefinition = {
|
||||||
|
key: PluginFeatureKey;
|
||||||
|
label: string;
|
||||||
|
array: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetadataItem =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null
|
||||||
|
| MetadataItem[]
|
||||||
|
| { [key: string]: MetadataItem };
|
||||||
|
|
||||||
|
type APITypeMetadata = {
|
||||||
|
label: string;
|
||||||
|
source: string;
|
||||||
|
count: number;
|
||||||
|
items: MetadataItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginMetadata = {
|
||||||
|
schemaVersion: 1;
|
||||||
|
apiTypes: PluginAPIKey[];
|
||||||
|
apis: Partial<Record<PluginAPIKey, APITypeMetadata>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const definitions: MetadataDefinition[] = [
|
||||||
|
{
|
||||||
|
key: "authentication",
|
||||||
|
label: "Authentication",
|
||||||
|
array: false,
|
||||||
|
},
|
||||||
|
{ key: "filter", label: "Filter", array: false },
|
||||||
|
{
|
||||||
|
key: "folderActions",
|
||||||
|
label: "Folder Action",
|
||||||
|
array: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "grpcRequestActions",
|
||||||
|
label: "gRPC Request Action",
|
||||||
|
array: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "httpRequestActions",
|
||||||
|
label: "HTTP Request Action",
|
||||||
|
array: true,
|
||||||
|
},
|
||||||
|
{ key: "importer", label: "Importer", array: false },
|
||||||
|
{
|
||||||
|
key: "templateFunctions",
|
||||||
|
label: "Template Tag",
|
||||||
|
array: true,
|
||||||
|
},
|
||||||
|
{ key: "themes", label: "Theme", array: true },
|
||||||
|
{
|
||||||
|
key: "websocketRequestActions",
|
||||||
|
label: "WebSocket Request Action",
|
||||||
|
array: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspaceActions",
|
||||||
|
label: "Workspace Action",
|
||||||
|
array: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generatePluginMetadata(
|
||||||
|
plugin: PluginDefinition,
|
||||||
|
): PluginMetadata {
|
||||||
|
const metadata: PluginMetadata = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
apiTypes: [],
|
||||||
|
apis: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const definition of definitions) {
|
||||||
|
const value = plugin[definition.key];
|
||||||
|
const items = definition.array ? value : value ? [value] : [];
|
||||||
|
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata.apiTypes.push(definition.key);
|
||||||
|
metadata.apis[definition.key] = {
|
||||||
|
label: definition.label,
|
||||||
|
source: definition.key,
|
||||||
|
count: items.length,
|
||||||
|
items: sanitize(items) as MetadataItem[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycleHooks = ["init", "dispose"].filter(
|
||||||
|
(key) =>
|
||||||
|
typeof plugin[key as keyof Pick<PluginDefinition, "init" | "dispose">] ===
|
||||||
|
"function",
|
||||||
|
);
|
||||||
|
if (lifecycleHooks.length > 0) {
|
||||||
|
metadata.apiTypes.push("lifecycle");
|
||||||
|
metadata.apis.lifecycle = {
|
||||||
|
label: "Lifecycle Hook",
|
||||||
|
source: lifecycleHooks.join(","),
|
||||||
|
count: lifecycleHooks.length,
|
||||||
|
items: lifecycleHooks.map((name) => ({ name })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPath = process.argv[1];
|
||||||
|
const outputPath = process.argv[2];
|
||||||
|
|
||||||
|
if (!entryPath) {
|
||||||
|
throw new Error("Missing plugin entrypoint path");
|
||||||
|
}
|
||||||
|
if (!outputPath) {
|
||||||
|
throw new Error("Missing plugin metadata output path");
|
||||||
|
}
|
||||||
|
|
||||||
|
const require = createRequire(path.join(process.cwd(), "plugin-metadata.js"));
|
||||||
|
const moduleExports = require(path.resolve(entryPath)) as PluginDefinition & {
|
||||||
|
plugin?: PluginDefinition;
|
||||||
|
default?: PluginDefinition;
|
||||||
|
};
|
||||||
|
const plugin = moduleExports.plugin ?? moduleExports.default ?? moduleExports;
|
||||||
|
|
||||||
|
if (!plugin || typeof plugin !== "object") {
|
||||||
|
throw new Error("Plugin entrypoint must export a plugin object");
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = generatePluginMetadata(plugin);
|
||||||
|
fs.writeFileSync(outputPath, `${JSON.stringify(metadata, null, 2)}\n`);
|
||||||
|
|
||||||
|
function sanitize(
|
||||||
|
value: unknown,
|
||||||
|
seen = new WeakSet<object>(),
|
||||||
|
): MetadataItem | undefined {
|
||||||
|
if (value === null) return null;
|
||||||
|
|
||||||
|
switch (typeof value) {
|
||||||
|
case "boolean":
|
||||||
|
case "number":
|
||||||
|
case "string":
|
||||||
|
return value;
|
||||||
|
case "bigint":
|
||||||
|
return value.toString();
|
||||||
|
case "function":
|
||||||
|
case "symbol":
|
||||||
|
case "undefined":
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectValue = value as object;
|
||||||
|
if (seen.has(objectValue)) {
|
||||||
|
return "[Circular]";
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(objectValue);
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const output = value.map((item) => sanitize(item, seen) ?? null);
|
||||||
|
seen.delete(objectValue);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: Record<string, MetadataItem> = {};
|
||||||
|
for (const [key, item] of Object.entries(objectValue)) {
|
||||||
|
const sanitized = sanitize(item, seen);
|
||||||
|
if (sanitized !== undefined) {
|
||||||
|
output[key] = sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.delete(objectValue);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ export type { DocumentPlatform, YaakColorKey, YaakColors, YaakTheme } from "./wi
|
|||||||
export {
|
export {
|
||||||
addThemeStylesToDocument,
|
addThemeStylesToDocument,
|
||||||
applyThemeToDocument,
|
applyThemeToDocument,
|
||||||
|
completeColorVariables,
|
||||||
|
completeFullColorVariables,
|
||||||
|
completePartialColorVariables,
|
||||||
completeTheme,
|
completeTheme,
|
||||||
getThemeCSS,
|
getThemeCSS,
|
||||||
indent,
|
indent,
|
||||||
|
|||||||
+125
-106
@@ -47,18 +47,10 @@ export type YaakTheme = {
|
|||||||
export type YaakColorKey = keyof ThemeComponentColors;
|
export type YaakColorKey = keyof ThemeComponentColors;
|
||||||
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
export type DocumentPlatform = "linux" | "macos" | "windows" | "unknown";
|
||||||
|
|
||||||
type ComponentName = keyof NonNullable<YaakTheme["components"]>;
|
type ComponentName = keyof NonNullable<Theme["components"]>;
|
||||||
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
type CSSVariables = Record<YaakColorKey, string | undefined>;
|
||||||
|
|
||||||
function themeVariables(
|
export function completeFullColorVariables(theme: Theme, cmp: Partial<CSSVariables>): CSSVariables {
|
||||||
theme: Theme,
|
|
||||||
component?: ComponentName,
|
|
||||||
base?: CSSVariables,
|
|
||||||
): CSSVariables | null {
|
|
||||||
const cmp =
|
|
||||||
component == null
|
|
||||||
? theme.base
|
|
||||||
: (theme.components?.[component] ?? ({} as ThemeComponentColors));
|
|
||||||
const color = (value: string | undefined) => yc(theme, value);
|
const color = (value: string | undefined) => yc(theme, value);
|
||||||
const vars: CSSVariables = {
|
const vars: CSSVariables = {
|
||||||
surface: cmp.surface,
|
surface: cmp.surface,
|
||||||
@@ -66,12 +58,12 @@ function themeVariables(
|
|||||||
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||||
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||||
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||||
border: cmp.border ?? color(cmp.surface)?.lift(0.11)?.css(),
|
border: cmp.border,
|
||||||
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06)?.css(),
|
borderSubtle: cmp.borderSubtle,
|
||||||
borderFocus: color(cmp.info)?.translucify(0.5)?.css(),
|
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5)?.css(),
|
||||||
text: cmp.text,
|
text: cmp.text,
|
||||||
textSubtle: cmp.textSubtle ?? color(cmp.text)?.lower(0.2)?.css(),
|
textSubtle: cmp.textSubtle,
|
||||||
textSubtlest: cmp.textSubtlest ?? color(cmp.text)?.lower(0.3)?.css(),
|
textSubtlest: cmp.textSubtlest,
|
||||||
shadow:
|
shadow:
|
||||||
cmp.shadow ??
|
cmp.shadow ??
|
||||||
YaakColor.black()
|
YaakColor.black()
|
||||||
@@ -86,96 +78,126 @@ function themeVariables(
|
|||||||
danger: cmp.danger,
|
danger: cmp.danger,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(vars)) {
|
const themeColor = (value: string) => new YaakColor(value, theme.dark ? "dark" : "light");
|
||||||
if (!value && base?.[key as YaakColorKey]) {
|
const themeSurface = themeColor(theme.dark ? "oklch(23% 0 0)" : "oklch(100% 0 0)");
|
||||||
vars[key as YaakColorKey] = base[key as YaakColorKey];
|
const surface = themeColor(vars.surface ?? themeSurface.css());
|
||||||
}
|
const reference = surface.compositeOver(themeSurface);
|
||||||
}
|
const seed = themeColor(vars.surface ?? vars.surfaceHighlight ?? vars.border ?? surface.css());
|
||||||
|
const textBase = seed.desaturate(0.6).opacify(1);
|
||||||
|
const borderBase = seed.opacify(1);
|
||||||
|
const text = vars.text ?? textBase.withContrast(reference, 11).css();
|
||||||
|
const textColor = themeColor(text);
|
||||||
|
|
||||||
return vars;
|
return normalizeColorVariables(theme, {
|
||||||
|
...vars,
|
||||||
|
text,
|
||||||
|
textSubtle: vars.textSubtle ?? textColor.lower(0.2).css(),
|
||||||
|
textSubtlest: vars.textSubtlest ?? textColor.lower(0.4).css(),
|
||||||
|
border: vars.border ?? borderBase.desaturate(0.2).withContrast(reference, 3).css(),
|
||||||
|
borderSubtle:
|
||||||
|
vars.borderSubtle ?? borderBase.desaturate(0.2).withContrast(reference, 1.2).css(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
export function completePartialColorVariables(
|
||||||
if (color == null) return {};
|
theme: Theme,
|
||||||
|
cmp: Partial<CSSVariables>,
|
||||||
|
): CSSVariables {
|
||||||
|
const color = (value: string | undefined) => yc(theme, value);
|
||||||
|
const text = color(cmp.text);
|
||||||
|
|
||||||
return {
|
return normalizeColorVariables(theme, {
|
||||||
text: color.lift(0.7).css(),
|
surface: cmp.surface,
|
||||||
textSubtle: color.lift(0.4).css(),
|
surfaceHighlight: cmp.surfaceHighlight ?? color(cmp.surface)?.lift(0.06).css(),
|
||||||
|
surfaceActive: cmp.surfaceActive ?? color(cmp.primary)?.lower(0.2).translucify(0.8).css(),
|
||||||
|
backdrop: cmp.backdrop ?? color(cmp.surface)?.lower(0.2).translucify(0.2).css(),
|
||||||
|
selection: cmp.selection ?? color(cmp.primary)?.lower(0.1).translucify(0.7).css(),
|
||||||
|
border: cmp.border ?? color(cmp.surface)?.lift(0.11).css(),
|
||||||
|
borderSubtle: cmp.borderSubtle ?? color(cmp.border)?.lower(0.06).css(),
|
||||||
|
borderFocus: cmp.borderFocus ?? color(cmp.info)?.translucify(0.5).css(),
|
||||||
|
text: cmp.text,
|
||||||
|
textSubtle: cmp.textSubtle ?? text?.lower(0.3).css(),
|
||||||
|
textSubtlest: cmp.textSubtlest ?? text?.lower(0.5).css(),
|
||||||
|
shadow:
|
||||||
|
cmp.shadow ??
|
||||||
|
YaakColor.black()
|
||||||
|
.translucify(theme.dark ? 0.7 : 0.93)
|
||||||
|
.css(),
|
||||||
|
primary: cmp.primary,
|
||||||
|
secondary: cmp.secondary,
|
||||||
|
info: cmp.info,
|
||||||
|
success: cmp.success,
|
||||||
|
notice: cmp.notice,
|
||||||
|
warning: cmp.warning,
|
||||||
|
danger: cmp.danger,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completeColorVariables = completeFullColorVariables;
|
||||||
|
|
||||||
|
function normalizeColorVariables(theme: Theme, vars: CSSVariables): CSSVariables {
|
||||||
|
const normalized: CSSVariables = {} as CSSVariables;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
normalized[key as YaakColorKey] = value == null ? undefined : yc(theme, value).css();
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateTagColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||||
|
return completeFullColorVariables(theme, {
|
||||||
|
text: color.liftMax().lower(0.05).css(),
|
||||||
|
textSubtle: color.liftMax().lower(0.08).css(),
|
||||||
textSubtlest: color.css(),
|
textSubtlest: color.css(),
|
||||||
surface: color.lower(0.2).translucify(0.8).css(),
|
surface: color.lower(0.2).translucify(0.8).css(),
|
||||||
border: color.translucify(0.6).css(),
|
border: color.translucify(0.6).css(),
|
||||||
borderSubtle: color.translucify(0.8).css(),
|
borderSubtle: color.translucify(0.8).css(),
|
||||||
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||||
if (color == null) return {};
|
return completeFullColorVariables(theme, {
|
||||||
|
|
||||||
return {
|
|
||||||
text: color.lift(0.8).css(),
|
|
||||||
textSubtle: color.lift(0.8).translucify(0.3).css(),
|
|
||||||
surface: color.translucify(0.9).css(),
|
surface: color.translucify(0.9).css(),
|
||||||
surfaceHighlight: color.translucify(0.8).css(),
|
surfaceHighlight: color.translucify(0.8).css(),
|
||||||
border: color.lift(0.3).translucify(0.6).css(),
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||||
if (color == null) return {};
|
return completeFullColorVariables(theme, {
|
||||||
|
|
||||||
return {
|
|
||||||
text: color.desaturate(0.5).lift(0.12).css(),
|
|
||||||
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
|
||||||
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
|
|
||||||
surface: color.translucify(0.95).css(),
|
surface: color.translucify(0.95).css(),
|
||||||
surfaceHighlight: color.translucify(0.85).css(),
|
surfaceHighlight: color.translucify(0.85).css(),
|
||||||
border: color.lift(0.3).translucify(0.8).css(),
|
border: color.lift(0.3).translucify(0.8).css(),
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function _inputCSS(color: YaakColor | null): Partial<CSSVariables> {
|
|
||||||
if (color == null) return {};
|
|
||||||
|
|
||||||
const theme: Partial<ThemeComponentColors> = {
|
|
||||||
border: color.css(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonSolidColorVariables(
|
function buttonSolidColorVariables(
|
||||||
color: YaakColor | null,
|
theme: Theme,
|
||||||
|
color: YaakColor,
|
||||||
isDefault = false,
|
isDefault = false,
|
||||||
): Partial<CSSVariables> {
|
): CSSVariables {
|
||||||
if (color == null) return {};
|
const vars: Partial<CSSVariables> = {
|
||||||
|
|
||||||
const theme: Partial<ThemeComponentColors> = {
|
|
||||||
text: "white",
|
|
||||||
surface: color.lower(0.3).css(),
|
surface: color.lower(0.3).css(),
|
||||||
surfaceHighlight: color.lower(0.1).css(),
|
surfaceHighlight: color.lower(0.1).css(),
|
||||||
border: color.css(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
theme.text = undefined;
|
vars.surface = undefined;
|
||||||
theme.surface = undefined;
|
vars.surfaceHighlight = color.lift(0.08).css();
|
||||||
theme.surfaceHighlight = color.lift(0.08).css();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return theme;
|
return completeFullColorVariables(theme, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonBorderColorVariables(
|
function buttonBorderColorVariables(
|
||||||
color: YaakColor | null,
|
theme: Theme,
|
||||||
|
color: YaakColor,
|
||||||
isDefault = false,
|
isDefault = false,
|
||||||
): Partial<CSSVariables> {
|
): CSSVariables {
|
||||||
if (color == null) return {};
|
|
||||||
|
|
||||||
const vars: Partial<CSSVariables> = {
|
const vars: Partial<CSSVariables> = {
|
||||||
text: color.lift(0.8).css(),
|
text: color.desaturate(0.4).lift(1).css(),
|
||||||
textSubtle: color.lift(0.55).css(),
|
textSubtle: color.desaturate(0.4).lift(0.55).css(),
|
||||||
textSubtlest: color.lift(0.4).translucify(0.6).css(),
|
|
||||||
surfaceHighlight: color.translucify(0.8).css(),
|
surfaceHighlight: color.translucify(0.8).css(),
|
||||||
borderSubtle: color.translucify(0.5).css(),
|
borderSubtle: color.translucify(0.5).css(),
|
||||||
border: color.translucify(0.3).css(),
|
border: color.translucify(0.3).css(),
|
||||||
@@ -186,7 +208,7 @@ function buttonBorderColorVariables(
|
|||||||
vars.border = color.lift(0.5).css();
|
vars.border = color.lift(0.5).css();
|
||||||
}
|
}
|
||||||
|
|
||||||
return vars;
|
return completeFullColorVariables(theme, vars);
|
||||||
}
|
}
|
||||||
|
|
||||||
function variablesToCSS(
|
function variablesToCSS(
|
||||||
@@ -203,9 +225,8 @@ function variablesToCSS(
|
|||||||
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
return selector == null ? css : `${selector} {\n${indent(css)}\n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function componentCSS(theme: Theme, component: ComponentName): string | null {
|
function componentCSS(component: ComponentName, vars: CSSVariables): string | null {
|
||||||
if (theme.components == null) return null;
|
return variablesToCSS(`.x-theme-${component}`, vars);
|
||||||
return variablesToCSS(`.x-theme-${component}`, themeVariables(theme, component));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buttonCSS(
|
function buttonCSS(
|
||||||
@@ -217,8 +238,11 @@ function buttonCSS(
|
|||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(color)),
|
variablesToCSS(`.x-theme-button--solid--${colorKey}`, buttonSolidColorVariables(theme, color)),
|
||||||
variablesToCSS(`.x-theme-button--border--${colorKey}`, buttonBorderColorVariables(color)),
|
variablesToCSS(
|
||||||
|
`.x-theme-button--border--${colorKey}`,
|
||||||
|
buttonBorderColorVariables(theme, color),
|
||||||
|
),
|
||||||
].join("\n\n");
|
].join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +254,7 @@ function bannerCSS(
|
|||||||
const color = yc(theme, colors?.[colorKey]);
|
const color = yc(theme, colors?.[colorKey]);
|
||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(color));
|
return variablesToCSS(`.x-theme-banner--${colorKey}`, bannerColorVariables(theme, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
function toastCSS(
|
function toastCSS(
|
||||||
@@ -241,7 +265,7 @@ function toastCSS(
|
|||||||
const color = yc(theme, colors?.[colorKey]);
|
const color = yc(theme, colors?.[colorKey]);
|
||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(color));
|
return variablesToCSS(`.x-theme-toast--${colorKey}`, toastColorVariables(theme, color));
|
||||||
}
|
}
|
||||||
|
|
||||||
function templateTagCSS(
|
function templateTagCSS(
|
||||||
@@ -252,7 +276,10 @@ function templateTagCSS(
|
|||||||
const color = yc(theme, colors?.[colorKey]);
|
const color = yc(theme, colors?.[colorKey]);
|
||||||
if (color == null) return null;
|
if (color == null) return null;
|
||||||
|
|
||||||
return variablesToCSS(`.x-theme-templateTag--${colorKey}`, templateTagColorVariables(color));
|
return variablesToCSS(
|
||||||
|
`.x-theme-templateTag--${colorKey}`,
|
||||||
|
templateTagColorVariables(theme, color),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getThemeCSS(theme: Theme): string {
|
export function getThemeCSS(theme: Theme): string {
|
||||||
@@ -265,17 +292,25 @@ export function getThemeCSS(theme: Theme): string {
|
|||||||
|
|
||||||
let themeCSS = "";
|
let themeCSS = "";
|
||||||
try {
|
try {
|
||||||
const baseCss = variablesToCSS(null, themeVariables(theme));
|
const baseCss = variablesToCSS(null, completeFullColorVariables(theme, theme.base));
|
||||||
|
const baseSurface = yc(theme, theme.base.surface);
|
||||||
|
|
||||||
themeCSS = [
|
themeCSS = [
|
||||||
baseCss,
|
baseCss,
|
||||||
...Object.keys(components).map((key) => componentCSS(theme, key as ComponentName)),
|
...Object.entries(components).map(([key, value]) =>
|
||||||
variablesToCSS(
|
componentCSS(key as ComponentName, completePartialColorVariables(theme, value ?? {})),
|
||||||
".x-theme-button--solid--default",
|
|
||||||
buttonSolidColorVariables(yc(theme, theme.base.surface), true),
|
|
||||||
),
|
),
|
||||||
variablesToCSS(
|
baseSurface == null
|
||||||
|
? null
|
||||||
|
: variablesToCSS(
|
||||||
|
".x-theme-button--solid--default",
|
||||||
|
buttonSolidColorVariables(theme, baseSurface, true),
|
||||||
|
),
|
||||||
|
baseSurface == null
|
||||||
|
? null
|
||||||
|
: variablesToCSS(
|
||||||
".x-theme-button--border--default",
|
".x-theme-button--border--default",
|
||||||
buttonBorderColorVariables(yc(theme, theme.base.surface), true),
|
buttonBorderColorVariables(theme, baseSurface, true),
|
||||||
),
|
),
|
||||||
...Object.keys(colors).map((key) =>
|
...Object.keys(colors).map((key) =>
|
||||||
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
buttonCSS(theme, key as YaakColorKey, theme.components?.button ?? colors),
|
||||||
@@ -361,26 +396,10 @@ function yc<T extends string | null | undefined>(
|
|||||||
|
|
||||||
export function completeTheme(theme: Theme): Theme {
|
export function completeTheme(theme: Theme): Theme {
|
||||||
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
const fallback = theme.dark ? defaultDarkTheme.base : defaultLightTheme.base;
|
||||||
const color = (value: string | null | undefined) => yc(theme, value);
|
|
||||||
|
|
||||||
theme.base.primary ??= fallback.primary;
|
for (const [key, value] of Object.entries(fallback)) {
|
||||||
theme.base.secondary ??= fallback.secondary;
|
theme.base[key as YaakColorKey] ??= value;
|
||||||
theme.base.info ??= fallback.info;
|
}
|
||||||
theme.base.success ??= fallback.success;
|
|
||||||
theme.base.notice ??= fallback.notice;
|
|
||||||
theme.base.warning ??= fallback.warning;
|
|
||||||
theme.base.danger ??= fallback.danger;
|
|
||||||
|
|
||||||
theme.base.surface ??= fallback.surface;
|
|
||||||
theme.base.surfaceHighlight ??= color(theme.base.surface)?.lift(0.06)?.css();
|
|
||||||
theme.base.surfaceActive ??= color(theme.base.primary)?.lower(0.2).translucify(0.8).css();
|
|
||||||
|
|
||||||
theme.base.border ??= color(theme.base.surface)?.lift(0.12)?.css();
|
|
||||||
theme.base.borderSubtle ??= color(theme.base.border)?.lower(0.08)?.css();
|
|
||||||
|
|
||||||
theme.base.text ??= fallback.text;
|
|
||||||
theme.base.textSubtle ??= color(theme.base.text)?.lower(0.3)?.css();
|
|
||||||
theme.base.textSubtlest ??= color(theme.base.text)?.lower(0.5)?.css();
|
|
||||||
|
|
||||||
return theme;
|
return theme;
|
||||||
}
|
}
|
||||||
|
|||||||
+254
-17
@@ -3,9 +3,9 @@ import parseColor from "parse-color";
|
|||||||
export class YaakColor {
|
export class YaakColor {
|
||||||
private readonly appearance: "dark" | "light" = "light";
|
private readonly appearance: "dark" | "light" = "light";
|
||||||
|
|
||||||
private hue = 0;
|
|
||||||
private saturation = 0;
|
|
||||||
private lightness = 0;
|
private lightness = 0;
|
||||||
|
private chroma = 0;
|
||||||
|
private hue = 0;
|
||||||
private alpha = 1;
|
private alpha = 1;
|
||||||
|
|
||||||
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
|
constructor(cssColor: string, appearance: "dark" | "light" = "light") {
|
||||||
@@ -22,11 +22,11 @@ export class YaakColor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static white(): YaakColor {
|
static white(): YaakColor {
|
||||||
return new YaakColor("rgb(0,0,0)", "light").lower(1);
|
return new YaakColor("rgb(0,0,0)", "light").lower(999);
|
||||||
}
|
}
|
||||||
|
|
||||||
static black(): YaakColor {
|
static black(): YaakColor {
|
||||||
return new YaakColor("rgb(0,0,0)", "light").lift(1);
|
return new YaakColor("rgb(0,0,0)", "light").lift(999);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(cssColor: string): YaakColor {
|
set(cssColor: string): YaakColor {
|
||||||
@@ -35,11 +35,22 @@ export class YaakColor {
|
|||||||
const [r, g, b, a] = hexToRgba(cssColor);
|
const [r, g, b, a] = hexToRgba(cssColor);
|
||||||
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
fixedCssColor = `rgba(${r},${g},${b},${a})`;
|
||||||
}
|
}
|
||||||
const { hsla } = parseColor(fixedCssColor);
|
|
||||||
this.hue = hsla[0];
|
const oklch = parseOklch(fixedCssColor);
|
||||||
this.saturation = hsla[1];
|
if (oklch != null) {
|
||||||
this.lightness = hsla[2];
|
this.lightness = oklch.lightness;
|
||||||
this.alpha = hsla[3] ?? 1;
|
this.chroma = oklch.chroma;
|
||||||
|
this.hue = oklch.hue;
|
||||||
|
this.alpha = oklch.alpha;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rgba } = parseColor(fixedCssColor);
|
||||||
|
const [lightness, chroma, hue] = rgbToOklch(rgba[0], rgba[1], rgba[2]);
|
||||||
|
this.lightness = lightness;
|
||||||
|
this.chroma = chroma;
|
||||||
|
this.hue = hue;
|
||||||
|
this.alpha = rgba[3] ?? 1;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +58,10 @@ export class YaakColor {
|
|||||||
return new YaakColor(this.css(), this.appearance);
|
return new YaakColor(this.css(), this.appearance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
themeColor(cssColor: string): YaakColor {
|
||||||
|
return new YaakColor(cssColor, this.appearance);
|
||||||
|
}
|
||||||
|
|
||||||
lower(mod: number): YaakColor {
|
lower(mod: number): YaakColor {
|
||||||
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
|
return this.appearance === "dark" ? this._darken(mod) : this._lighten(mod);
|
||||||
}
|
}
|
||||||
@@ -55,6 +70,21 @@ export class YaakColor {
|
|||||||
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
|
return this.appearance === "dark" ? this._lighten(mod) : this._darken(mod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
liftMax(): YaakColor {
|
||||||
|
return this.lift(999);
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerMax(): YaakColor {
|
||||||
|
return this.lower(999);
|
||||||
|
}
|
||||||
|
|
||||||
|
themeSurface(): YaakColor {
|
||||||
|
return new YaakColor(
|
||||||
|
this.appearance === "dark" ? "oklch(23% 0 0)" : "oklch(100% 0 0)",
|
||||||
|
this.appearance,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
minLightness(n: number): YaakColor {
|
minLightness(n: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
if (color.lightness < n) {
|
if (color.lightness < n) {
|
||||||
@@ -69,25 +99,25 @@ export class YaakColor {
|
|||||||
|
|
||||||
translucify(mod: number): YaakColor {
|
translucify(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.alpha = color.alpha - color.alpha * mod;
|
color.alpha = clamp(color.alpha - color.alpha * mod, 0, 1);
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
opacify(mod: number): YaakColor {
|
opacify(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.alpha = this.alpha + (100 - this.alpha) * mod;
|
color.alpha = clamp(this.alpha + (1 - this.alpha) * mod, 0, 1);
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
desaturate(mod: number): YaakColor {
|
desaturate(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.saturation = color.saturation - color.saturation * mod;
|
color.chroma = color.chroma - color.chroma * mod;
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
saturate(mod: number): YaakColor {
|
saturate(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.saturation = this.saturation + (100 - this.saturation) * mod;
|
color.chroma = this.chroma + this.chroma * mod;
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,29 +125,236 @@ export class YaakColor {
|
|||||||
return this.lightness > color.lightness;
|
return this.lightness > color.lightness;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contrastRatio(background: YaakColor): number {
|
||||||
|
const foreground = this.alpha < 1 ? this.compositeOver(background) : this;
|
||||||
|
const foregroundLuminance = foreground.relativeLuminance();
|
||||||
|
const backgroundLuminance = background.relativeLuminance();
|
||||||
|
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
||||||
|
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
withContrast(background: YaakColor, minContrast: number): YaakColor {
|
||||||
|
const darker = this.clone();
|
||||||
|
darker.lightness = 0;
|
||||||
|
darker.chroma = 0;
|
||||||
|
darker.hue = 0;
|
||||||
|
|
||||||
|
const lighter = this.clone();
|
||||||
|
lighter.lightness = 100;
|
||||||
|
lighter.chroma = 0;
|
||||||
|
lighter.hue = 0;
|
||||||
|
|
||||||
|
const darkerContrast = darker.contrastRatio(background);
|
||||||
|
const lighterContrast = lighter.contrastRatio(background);
|
||||||
|
let useLighterColor = lighterContrast >= darkerContrast;
|
||||||
|
|
||||||
|
// Saturated accent surfaces often read better with white text even when
|
||||||
|
// black has the higher numeric contrast. Keep yellow-ish light accents dark
|
||||||
|
// by requiring white to clear a modest contrast floor first.
|
||||||
|
if (minContrast >= 3 && lighterContrast >= 2.5) {
|
||||||
|
useLighterColor = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedContrast = useLighterColor ? lighterContrast : darkerContrast;
|
||||||
|
if (selectedContrast < minContrast) {
|
||||||
|
return useLighterColor ? lighter : darker;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minLightness = 0;
|
||||||
|
let maxLightness = 100;
|
||||||
|
const color = this.clone();
|
||||||
|
|
||||||
|
for (let i = 0; i < 24; i += 1) {
|
||||||
|
color.lightness = (minLightness + maxLightness) / 2;
|
||||||
|
const contrast = color.contrastRatio(background);
|
||||||
|
|
||||||
|
if (useLighterColor) {
|
||||||
|
if (contrast >= minContrast) {
|
||||||
|
maxLightness = color.lightness;
|
||||||
|
} else {
|
||||||
|
minLightness = color.lightness;
|
||||||
|
}
|
||||||
|
} else if (contrast >= minContrast) {
|
||||||
|
minLightness = color.lightness;
|
||||||
|
} else {
|
||||||
|
maxLightness = color.lightness;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
color.lightness = useLighterColor ? maxLightness : minLightness;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
compositeOver(background: YaakColor): YaakColor {
|
||||||
|
const [fgR, fgG, fgB] = this.rgb();
|
||||||
|
const [bgR, bgG, bgB] = background.rgb();
|
||||||
|
const alpha = this.alpha + background.alpha * (1 - this.alpha);
|
||||||
|
|
||||||
|
if (alpha <= 0) {
|
||||||
|
return YaakColor.transparent();
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = (fgR * this.alpha + bgR * background.alpha * (1 - this.alpha)) / alpha;
|
||||||
|
const g = (fgG * this.alpha + bgG * background.alpha * (1 - this.alpha)) / alpha;
|
||||||
|
const b = (fgB * this.alpha + bgB * background.alpha * (1 - this.alpha)) / alpha;
|
||||||
|
|
||||||
|
return new YaakColor(`rgba(${r},${g},${b},${alpha})`, this.appearance);
|
||||||
|
}
|
||||||
|
|
||||||
css(): string {
|
css(): string {
|
||||||
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
|
const [r, g, b] = this.rgb();
|
||||||
return rgbaToHex(r, g, b, this.alpha);
|
return rgbaToHex(r, g, b, this.alpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
hexNoAlpha(): string {
|
hexNoAlpha(): string {
|
||||||
const [r, g, b] = parseColor(`hsl(${this.hue},${this.saturation}%,${this.lightness}%)`).rgb;
|
const [r, g, b] = this.rgb();
|
||||||
return rgbaToHexNoAlpha(r, g, b);
|
return rgbaToHexNoAlpha(r, g, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private relativeLuminance(): number {
|
||||||
|
const [r, g, b] = this.rgb();
|
||||||
|
const red = srgbToLinear(r / 255);
|
||||||
|
const green = srgbToLinear(g / 255);
|
||||||
|
const blue = srgbToLinear(b / 255);
|
||||||
|
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rgb(): [number, number, number] {
|
||||||
|
return oklchToRgb(this.lightness, this.chroma, this.hue);
|
||||||
|
}
|
||||||
|
|
||||||
private _lighten(mod: number): YaakColor {
|
private _lighten(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.lightness = this.lightness + (100 - this.lightness) * mod;
|
color.lightness = clamp(this.lightness + (100 - this.lightness) * mod, 0, 100);
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _darken(mod: number): YaakColor {
|
private _darken(mod: number): YaakColor {
|
||||||
const color = this.clone();
|
const color = this.clone();
|
||||||
color.lightness = this.lightness - this.lightness * mod;
|
color.lightness = clamp(this.lightness - this.lightness * mod, 0, 100);
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseOklch(
|
||||||
|
cssColor: string,
|
||||||
|
): { lightness: number; chroma: number; hue: number; alpha: number } | null {
|
||||||
|
const match = cssColor
|
||||||
|
.trim()
|
||||||
|
.match(
|
||||||
|
/^oklch\(\s*([^\s,]+)(?:\s+|,\s*)([^\s,]+)(?:\s+|,\s*)([^\s,/]+)(?:\s*\/\s*([^)]+)|(?:\s*,\s*([^)]*))?)\s*\)$/i,
|
||||||
|
);
|
||||||
|
if (match == null) return null;
|
||||||
|
|
||||||
|
const [, lightnessValue, chromaValue, hueValue, slashAlpha, commaAlpha] = match;
|
||||||
|
if (lightnessValue == null || chromaValue == null || hueValue == null) return null;
|
||||||
|
|
||||||
|
const lightness = parseOklchLightness(lightnessValue);
|
||||||
|
const chroma = parseCssNumber(chromaValue, 1);
|
||||||
|
const hue = normalizeHue(parseCssNumber(hueValue.replace(/deg$/i, ""), 1));
|
||||||
|
const alpha = parseCssNumber(slashAlpha ?? commaAlpha ?? "1", 1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(lightness) ||
|
||||||
|
!Number.isFinite(chroma) ||
|
||||||
|
!Number.isFinite(hue) ||
|
||||||
|
!Number.isFinite(alpha)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lightness: clamp(lightness, 0, 100),
|
||||||
|
chroma: Math.max(0, chroma),
|
||||||
|
hue,
|
||||||
|
alpha: clamp(alpha, 0, 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssNumber(value: string, percentScale: number): number {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.endsWith("%")) {
|
||||||
|
return (Number.parseFloat(normalized) / 100) * percentScale;
|
||||||
|
}
|
||||||
|
return Number.parseFloat(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOklchLightness(value: string): number {
|
||||||
|
const parsed = parseCssNumber(value, 100);
|
||||||
|
return value.trim().endsWith("%") || parsed > 1 ? parsed : parsed * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
const red = srgbToLinear(r / 255);
|
||||||
|
const green = srgbToLinear(g / 255);
|
||||||
|
const blue = srgbToLinear(b / 255);
|
||||||
|
|
||||||
|
const l = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue;
|
||||||
|
const m = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue;
|
||||||
|
const s = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue;
|
||||||
|
|
||||||
|
const lRoot = Math.cbrt(l);
|
||||||
|
const mRoot = Math.cbrt(m);
|
||||||
|
const sRoot = Math.cbrt(s);
|
||||||
|
|
||||||
|
const lightness = 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot;
|
||||||
|
const a = 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot;
|
||||||
|
const okb = 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot;
|
||||||
|
|
||||||
|
return [
|
||||||
|
lightness * 100,
|
||||||
|
Math.sqrt(a * a + okb * okb),
|
||||||
|
normalizeHue(radToDeg(Math.atan2(okb, a))),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function oklchToRgb(lightness: number, chroma: number, hue: number): [number, number, number] {
|
||||||
|
const l = clamp(lightness, 0, 100) / 100;
|
||||||
|
const a = Math.cos(degToRad(hue)) * chroma;
|
||||||
|
const b = Math.sin(degToRad(hue)) * chroma;
|
||||||
|
|
||||||
|
const lRoot = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const mRoot = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const sRoot = l - 0.0894841775 * a - 1.291485548 * b;
|
||||||
|
|
||||||
|
const lCube = lRoot * lRoot * lRoot;
|
||||||
|
const mCube = mRoot * mRoot * mRoot;
|
||||||
|
const sCube = sRoot * sRoot * sRoot;
|
||||||
|
|
||||||
|
const red = 4.0767416621 * lCube - 3.3077115913 * mCube + 0.2309699292 * sCube;
|
||||||
|
const green = -1.2684380046 * lCube + 2.6097574011 * mCube - 0.3413193965 * sCube;
|
||||||
|
const blue = -0.0041960863 * lCube - 0.7034186147 * mCube + 1.707614701 * sCube;
|
||||||
|
|
||||||
|
return [linearToSrgb(red) * 255, linearToSrgb(green) * 255, linearToSrgb(blue) * 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
function srgbToLinear(value: number): number {
|
||||||
|
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearToSrgb(value: number): number {
|
||||||
|
const srgb = value <= 0.0031308 ? value * 12.92 : 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
|
||||||
|
return clamp(srgb, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHue(value: number): number {
|
||||||
|
const hue = value % 360;
|
||||||
|
return hue < 0 ? hue + 360 : hue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function degToRad(value: number): number {
|
||||||
|
return (value * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function radToDeg(value: number): number {
|
||||||
|
return (value * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
function rgbaToHex(r: number, g: number, b: number, a: number): string {
|
function rgbaToHex(r: number, g: number, b: number, a: number): string {
|
||||||
const toHex = (n: number): string => {
|
const toHex = (n: number): string => {
|
||||||
const hex = Number(Math.round(n)).toString(16);
|
const hex = Number(Math.round(n)).toString(16);
|
||||||
|
|||||||
@@ -364,6 +364,8 @@ function TreeItem_<T extends { id: string }>({
|
|||||||
ref={handleEditFocus}
|
ref={handleEditFocus}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoCorrect="off"
|
||||||
className="bg-transparent outline-none w-full cursor-text"
|
className="bg-transparent outline-none w-full cursor-text"
|
||||||
onBlur={handleEditBlur}
|
onBlur={handleEditBlur}
|
||||||
onKeyDown={handleEditKeyDown}
|
onKeyDown={handleEditKeyDown}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
"test": "vp test --run tests"
|
"test": "vp test --run tests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openapi-to-postmanv2": "^5.8.0",
|
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/openapi-to-postmanv2": "^5.0.0"
|
"@types/openapi-to-postmanv2": "^5.0.0",
|
||||||
|
"openapi-to-postmanv2": "^5.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { convertPostman } from "@yaak/importer-postman/src";
|
import type {
|
||||||
import type { Context, PluginDefinition } from "@yaakapp/api";
|
Context,
|
||||||
|
Environment,
|
||||||
|
Folder,
|
||||||
|
HttpRequest,
|
||||||
|
HttpRequestHeader,
|
||||||
|
HttpUrlParameter,
|
||||||
|
PartialImportResources,
|
||||||
|
PluginDefinition,
|
||||||
|
Workspace,
|
||||||
|
} from "@yaakapp/api";
|
||||||
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
||||||
import { convert } from "openapi-to-postmanv2";
|
import YAML from "yaml";
|
||||||
|
|
||||||
|
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
type ImportResources = {
|
||||||
|
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
|
||||||
|
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId" | "variables">[];
|
||||||
|
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
|
||||||
|
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
|
||||||
|
const BODY_CONTENT_TYPE_PREFERENCE = [
|
||||||
|
"application/json",
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
"multipart/form-data",
|
||||||
|
"application/xml",
|
||||||
|
"text/plain",
|
||||||
|
];
|
||||||
|
const MAX_EXAMPLE_DEPTH = 8;
|
||||||
|
const MAX_EXAMPLE_PROPERTIES = 25;
|
||||||
|
const MAX_DESCRIPTION_ITEMS = 40;
|
||||||
|
|
||||||
export const plugin: PluginDefinition = {
|
export const plugin: PluginDefinition = {
|
||||||
importer: {
|
importer: {
|
||||||
@@ -14,23 +44,785 @@ export const plugin: PluginDefinition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
|
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
|
||||||
// oxlint-disable-next-line no-explicit-any
|
const spec = parseSpec(contents);
|
||||||
let postmanCollection: any;
|
if (!isOpenApiSpec(spec)) return undefined;
|
||||||
try {
|
|
||||||
postmanCollection = await new Promise((resolve, reject) => {
|
|
||||||
// oxlint-disable-next-line no-explicit-any
|
|
||||||
convert({ type: "string", data: contents }, {}, (err, result: any) => {
|
|
||||||
if (err != null) reject(err);
|
|
||||||
|
|
||||||
if (Array.isArray(result.output) && result.output.length > 0) {
|
const importState = new ImportState(spec);
|
||||||
resolve(result.output[0].data);
|
const workspace: ImportResources["workspaces"][0] = {
|
||||||
}
|
model: "workspace",
|
||||||
|
id: importState.generateId("workspace"),
|
||||||
|
name: stringAt(spec.info, "title") ?? "OpenAPI Import",
|
||||||
|
description: importInfoDescription(toRecord(spec.info)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resources: ImportResources = {
|
||||||
|
workspaces: [workspace],
|
||||||
|
environments: [],
|
||||||
|
folders: [],
|
||||||
|
httpRequests: [],
|
||||||
|
};
|
||||||
|
const baseUrl = importBaseUrl(spec);
|
||||||
|
const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
|
||||||
|
|
||||||
|
if (baseUrl.length > 0) {
|
||||||
|
resources.environments.push({
|
||||||
|
model: "environment",
|
||||||
|
id: importState.generateId("environment"),
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name: "Global Variables",
|
||||||
|
variables: [{ name: "baseUrl", value: baseUrl }],
|
||||||
|
parentModel: "workspace",
|
||||||
|
parentId: null,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
});
|
});
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Probably not an OpenAPI file, so skip it
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return convertPostman(JSON.stringify(postmanCollection));
|
const folderIdsByTag = new Map<string, string>();
|
||||||
|
for (const tag of toArray(spec.tags)) {
|
||||||
|
const tagRecord = toRecord(tag);
|
||||||
|
const name = stringAt(tagRecord, "name");
|
||||||
|
if (name == null || folderIdsByTag.has(name)) continue;
|
||||||
|
|
||||||
|
const folder: ImportResources["folders"][0] = {
|
||||||
|
model: "folder",
|
||||||
|
id: importState.generateId("folder"),
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name,
|
||||||
|
description: importTagDescription(tagRecord),
|
||||||
|
folderId: null,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
};
|
||||||
|
resources.folders.push(folder);
|
||||||
|
folderIdsByTag.set(name, folder.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
||||||
|
const pathItem = importState.resolve(rawPathItem);
|
||||||
|
if (!isRecord(pathItem)) continue;
|
||||||
|
|
||||||
|
const pathParameters = toArray(pathItem.parameters);
|
||||||
|
for (const method of HTTP_METHODS) {
|
||||||
|
const operation = importState.resolve(pathItem[method]);
|
||||||
|
if (!isRecord(operation)) continue;
|
||||||
|
|
||||||
|
const folderId = findOrCreateFolderId({
|
||||||
|
folderIdsByTag,
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
resources,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
resources.httpRequests.push(
|
||||||
|
importOperation({
|
||||||
|
importState,
|
||||||
|
method,
|
||||||
|
operation,
|
||||||
|
path: rawPath,
|
||||||
|
pathParameters,
|
||||||
|
requestBaseUrl,
|
||||||
|
spec,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
folderId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.httpRequests.length === 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resources: deleteUndefinedAttrs(
|
||||||
|
convertTemplateSyntax({
|
||||||
|
environments: resources.environments,
|
||||||
|
folders: resources.folders,
|
||||||
|
grpcRequests: [],
|
||||||
|
httpRequests: resources.httpRequests,
|
||||||
|
websocketRequests: [],
|
||||||
|
workspaces: resources.workspaces,
|
||||||
|
}),
|
||||||
|
) as PartialImportResources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOperation({
|
||||||
|
importState,
|
||||||
|
method,
|
||||||
|
operation,
|
||||||
|
path,
|
||||||
|
pathParameters,
|
||||||
|
requestBaseUrl,
|
||||||
|
spec,
|
||||||
|
workspaceId,
|
||||||
|
folderId,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
method: string;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
path: string;
|
||||||
|
pathParameters: unknown[];
|
||||||
|
requestBaseUrl: string;
|
||||||
|
spec: UnknownRecord;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
}): ImportResources["httpRequests"][0] {
|
||||||
|
const parameters = [...pathParameters, ...toArray(operation.parameters)].map((p) =>
|
||||||
|
importState.resolve(p),
|
||||||
|
);
|
||||||
|
const body = importBody({ importState, operation, parameters, spec });
|
||||||
|
const urlParameters = importUrlParameters({ importState, parameters });
|
||||||
|
const headers = mergeHeaders(importHeaderParameters({ importState, parameters }), body.headers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: "http_request",
|
||||||
|
id: importState.generateId("http_request"),
|
||||||
|
workspaceId,
|
||||||
|
folderId,
|
||||||
|
name: importOperationName(operation, method, path),
|
||||||
|
description: importOperationDescription({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
bodyContentType: body.bodyType,
|
||||||
|
}),
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: buildOperationUrl(requestBaseUrl, path),
|
||||||
|
urlParameters,
|
||||||
|
headers,
|
||||||
|
body: body.body,
|
||||||
|
bodyType: body.bodyType,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
...importAuthentication({ importState, operation, spec }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpec(contents: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(contents);
|
||||||
|
} catch {
|
||||||
|
// Fall through to YAML.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return YAML.parse(contents);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenApiSpec(value: unknown): value is UnknownRecord {
|
||||||
|
const spec = toRecord(value);
|
||||||
|
const openapi = stringAt(spec, "openapi");
|
||||||
|
const swagger = stringAt(spec, "swagger");
|
||||||
|
return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function importInfoDescription(info: UnknownRecord): string | undefined {
|
||||||
|
const parts = [
|
||||||
|
stringAt(info, "description"),
|
||||||
|
stringAt(info, "termsOfService")
|
||||||
|
? `Terms of service: ${stringAt(info, "termsOfService")}`
|
||||||
|
: null,
|
||||||
|
isRecord(info.contact) && stringAt(info.contact, "email")
|
||||||
|
? `Contact: ${stringAt(info.contact, "email")}`
|
||||||
|
: null,
|
||||||
|
isRecord(info.license) && stringAt(info.license, "name")
|
||||||
|
? `License: ${stringAt(info.license, "name")}${
|
||||||
|
stringAt(info.license, "url") ? ` (${stringAt(info.license, "url")})` : ""
|
||||||
|
}`
|
||||||
|
: null,
|
||||||
|
].filter(isPresent);
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importTagDescription(tag: UnknownRecord): string | undefined {
|
||||||
|
const externalDocs = toRecord(tag.externalDocs);
|
||||||
|
const parts = [
|
||||||
|
stringAt(tag, "description"),
|
||||||
|
stringAt(externalDocs, "url")
|
||||||
|
? `${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`
|
||||||
|
: null,
|
||||||
|
].filter(isPresent);
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOperationName(operation: UnknownRecord, method: string, path: string): string {
|
||||||
|
return (
|
||||||
|
stringAt(operation, "summary") ??
|
||||||
|
stringAt(operation, "operationId") ??
|
||||||
|
`${method.toUpperCase()} ${path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOperationDescription({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
bodyContentType,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
parameters: unknown[];
|
||||||
|
bodyContentType: string | null;
|
||||||
|
}): string | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const summary = stringAt(operation, "summary");
|
||||||
|
const description = stringAt(operation, "description");
|
||||||
|
const operationId = stringAt(operation, "operationId");
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
parts.push(description);
|
||||||
|
} else if (summary != null) {
|
||||||
|
parts.push(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operationId != null) {
|
||||||
|
parts.push(`Operation ID: ${operationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameterDescriptions = parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.slice(0, MAX_DESCRIPTION_ITEMS)
|
||||||
|
.map((p) => {
|
||||||
|
const name = stringAt(p, "name") ?? "parameter";
|
||||||
|
const location = stringAt(p, "in") ?? "unknown";
|
||||||
|
const required = p.required === true ? ", required" : "";
|
||||||
|
const description = stringAt(p, "description");
|
||||||
|
return `- ${name} (${location}${required})${description ? `: ${description}` : ""}`;
|
||||||
|
});
|
||||||
|
if (parameterDescriptions.length > 0) {
|
||||||
|
parts.push(["Parameters:", ...parameterDescriptions].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = importState.resolve(operation.requestBody);
|
||||||
|
if (isRecord(requestBody)) {
|
||||||
|
const content = toRecord(requestBody.content);
|
||||||
|
const contentTypes = Object.keys(content);
|
||||||
|
const bodyLines = [
|
||||||
|
stringAt(requestBody, "description"),
|
||||||
|
bodyContentType ? `Selected content type: ${bodyContentType}` : null,
|
||||||
|
contentTypes.length > 0 ? `Available content types: ${contentTypes.join(", ")}` : null,
|
||||||
|
].filter(isPresent);
|
||||||
|
if (bodyLines.length > 0) {
|
||||||
|
parts.push(["Request body:", ...bodyLines].join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseDescriptions = Object.entries(toRecord(operation.responses))
|
||||||
|
.slice(0, MAX_DESCRIPTION_ITEMS)
|
||||||
|
.map(([status, response]) => {
|
||||||
|
const responseRecord = toRecord(importState.resolve(response));
|
||||||
|
return `- ${status}: ${stringAt(responseRecord, "description") ?? ""}`.trimEnd();
|
||||||
|
});
|
||||||
|
if (responseDescriptions.length > 0) {
|
||||||
|
parts.push(["Responses:", ...responseDescriptions].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalDocs = toRecord(operation.externalDocs);
|
||||||
|
if (stringAt(externalDocs, "url")) {
|
||||||
|
parts.push(
|
||||||
|
`${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOrCreateFolderId({
|
||||||
|
folderIdsByTag,
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
resources,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
folderIdsByTag: Map<string, string>;
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
resources: ImportResources;
|
||||||
|
workspaceId: string;
|
||||||
|
}): string | null {
|
||||||
|
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
||||||
|
if (tag == null) return null;
|
||||||
|
|
||||||
|
const existingFolderId = folderIdsByTag.get(tag);
|
||||||
|
if (existingFolderId != null) return existingFolderId;
|
||||||
|
|
||||||
|
const folder: ImportResources["folders"][0] = {
|
||||||
|
model: "folder",
|
||||||
|
id: importState.generateId("folder"),
|
||||||
|
workspaceId,
|
||||||
|
name: tag,
|
||||||
|
folderId: null,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
};
|
||||||
|
resources.folders.push(folder);
|
||||||
|
folderIdsByTag.set(tag, folder.id);
|
||||||
|
return folder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOperationUrl(baseUrl: string, path: string): string {
|
||||||
|
return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importBaseUrl(spec: UnknownRecord): string {
|
||||||
|
const openApiServer = toArray(spec.servers)
|
||||||
|
.map((s) => toRecord(s))
|
||||||
|
.map((s) => interpolateServerUrl(s))
|
||||||
|
.find((url) => url.length > 0);
|
||||||
|
if (openApiServer != null) return openApiServer;
|
||||||
|
|
||||||
|
const host = stringAt(spec, "host");
|
||||||
|
if (host == null) return stringAt(spec, "basePath") ?? "";
|
||||||
|
|
||||||
|
const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https";
|
||||||
|
return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateServerUrl(server: UnknownRecord): string {
|
||||||
|
let url = stringAt(server, "url") ?? "";
|
||||||
|
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
|
||||||
|
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrlParts(baseUrl: string, path: string): string {
|
||||||
|
if (baseUrl.length === 0) return path;
|
||||||
|
return `${trimTrailingSlashes(baseUrl)}/${trimLeadingSlashes(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimLeadingSlashes(value: string): string {
|
||||||
|
let index = 0;
|
||||||
|
while (value[index] === "/") index++;
|
||||||
|
return value.slice(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingSlashes(value: string): string {
|
||||||
|
let index = value.length;
|
||||||
|
while (value[index - 1] === "/") index--;
|
||||||
|
return value.slice(0, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importUrlParameters({
|
||||||
|
importState,
|
||||||
|
parameters,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
parameters: unknown[];
|
||||||
|
}): HttpUrlParameter[] {
|
||||||
|
return parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
|
||||||
|
.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name:
|
||||||
|
stringAt(p, "in") === "path"
|
||||||
|
? `:${stringAt(p, "name") ?? ""}`
|
||||||
|
: (stringAt(p, "name") ?? ""),
|
||||||
|
value: parameterExample(p, importState),
|
||||||
|
}))
|
||||||
|
.filter(({ name }) => name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importHeaderParameters({
|
||||||
|
importState,
|
||||||
|
parameters,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
parameters: unknown[];
|
||||||
|
}): HttpRequestHeader[] {
|
||||||
|
return parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "header")
|
||||||
|
.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name: stringAt(p, "name") ?? "",
|
||||||
|
value: parameterExample(p, importState),
|
||||||
|
}))
|
||||||
|
.filter(({ name }) => name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
|
||||||
|
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
|
||||||
|
if (directExample != null) return stringifyExampleValue(directExample);
|
||||||
|
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importBody({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
spec,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
parameters: unknown[];
|
||||||
|
spec: UnknownRecord;
|
||||||
|
}): {
|
||||||
|
headers: HttpRequestHeader[];
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
bodyType: string | null;
|
||||||
|
} {
|
||||||
|
const openApiRequestBody = importState.resolve(operation.requestBody);
|
||||||
|
if (isRecord(openApiRequestBody)) {
|
||||||
|
return importBodyFromContent(importState, toRecord(openApiRequestBody.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyParameter = parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
|
||||||
|
if (isRecord(bodyParameter)) {
|
||||||
|
const contentType = toArray(spec.consumes).find((c): c is string => typeof c === "string");
|
||||||
|
const bodyType = contentType ?? "application/json";
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
|
||||||
|
bodyType,
|
||||||
|
body: {
|
||||||
|
text: formatBodyText(
|
||||||
|
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formParameters = parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "formData");
|
||||||
|
if (formParameters.length > 0) {
|
||||||
|
const contentType =
|
||||||
|
toArray(spec.consumes).find((c): c is string => typeof c === "string") ??
|
||||||
|
(formParameters.some((p) => stringAt(p, "type") === "file")
|
||||||
|
? "multipart/form-data"
|
||||||
|
: "application/x-www-form-urlencoded");
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
|
bodyType: contentType,
|
||||||
|
body: {
|
||||||
|
form: formParameters.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name: stringAt(p, "name") ?? "",
|
||||||
|
value: parameterExample(p, importState),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers: [], body: {}, bodyType: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function importBodyFromContent(importState: ImportState, content: UnknownRecord) {
|
||||||
|
const contentType = chooseContentType(Object.keys(content));
|
||||||
|
if (contentType == null) return { headers: [], body: {}, bodyType: null };
|
||||||
|
|
||||||
|
const mediaType = toRecord(content[contentType]);
|
||||||
|
const example = mediaTypeExample(mediaType, importState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType === "application/x-www-form-urlencoded" ||
|
||||||
|
contentType === "multipart/form-data"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
|
bodyType: contentType,
|
||||||
|
body: {
|
||||||
|
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
|
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
|
||||||
|
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseContentType(contentTypes: string[]): string | null {
|
||||||
|
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
|
||||||
|
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
|
||||||
|
if (exact != null) return exact;
|
||||||
|
}
|
||||||
|
return contentTypes[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
|
||||||
|
const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples));
|
||||||
|
if (directExample != null) return directExample;
|
||||||
|
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
||||||
|
const resolvedSchema = toRecord(importState.resolve(schema));
|
||||||
|
const required = toArray(resolvedSchema.required).filter(
|
||||||
|
(name): name is string => typeof name === "string",
|
||||||
|
);
|
||||||
|
const properties = Object.entries(toRecord(resolvedSchema.properties)).slice(
|
||||||
|
0,
|
||||||
|
MAX_EXAMPLE_PROPERTIES,
|
||||||
|
);
|
||||||
|
|
||||||
|
return properties.map(([name, property]) => {
|
||||||
|
const resolvedProperty = toRecord(importState.resolve(property));
|
||||||
|
const example = schemaToExample(resolvedProperty, importState);
|
||||||
|
const base = {
|
||||||
|
enabled: required.includes(name),
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
if (stringAt(resolvedProperty, "format") === "binary") {
|
||||||
|
return { ...base, file: "" };
|
||||||
|
}
|
||||||
|
return { ...base, value: stringifyExampleValue(example) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaToExample(
|
||||||
|
schema: unknown,
|
||||||
|
importState: ImportState,
|
||||||
|
depth = 0,
|
||||||
|
visitedRefs = new Set<string>(),
|
||||||
|
): unknown {
|
||||||
|
if (depth > MAX_EXAMPLE_DEPTH) return {};
|
||||||
|
|
||||||
|
const resolved = importState.resolve(schema, visitedRefs);
|
||||||
|
if (!isRecord(resolved)) return "";
|
||||||
|
|
||||||
|
const explicitExample = firstPresent(
|
||||||
|
resolved.example,
|
||||||
|
firstExampleValue(resolved.examples),
|
||||||
|
resolved.default,
|
||||||
|
);
|
||||||
|
if (explicitExample != null) return explicitExample;
|
||||||
|
|
||||||
|
const enumValues = toArray(resolved.enum);
|
||||||
|
if (enumValues.length > 0) return enumValues[0];
|
||||||
|
|
||||||
|
const allOf = toArray(resolved.allOf);
|
||||||
|
if (allOf.length > 0) {
|
||||||
|
return allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
||||||
|
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
|
||||||
|
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneOf = toArray(resolved.oneOf);
|
||||||
|
const anyOf = toArray(resolved.anyOf);
|
||||||
|
if (oneOf.length > 0 || anyOf.length > 0) {
|
||||||
|
return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = inferSchemaType(resolved);
|
||||||
|
if (type === "array") {
|
||||||
|
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
|
||||||
|
}
|
||||||
|
if (type === "object") {
|
||||||
|
const required = toArray(resolved.required).filter(
|
||||||
|
(name): name is string => typeof name === "string",
|
||||||
|
);
|
||||||
|
const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => {
|
||||||
|
const aRequired = required.includes(a);
|
||||||
|
const bRequired = required.includes(b);
|
||||||
|
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
properties
|
||||||
|
.slice(0, MAX_EXAMPLE_PROPERTIES)
|
||||||
|
.map(([name, property]) => [
|
||||||
|
name,
|
||||||
|
schemaToExample(property, importState, depth + 1, visitedRefs),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === "integer" || type === "number") return 0;
|
||||||
|
if (type === "boolean") return false;
|
||||||
|
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
|
||||||
|
if (stringAt(resolved, "format") === "date") return "2026-01-01";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSchemaType(schema: UnknownRecord): string {
|
||||||
|
const rawType = schema.type;
|
||||||
|
if (typeof rawType === "string") return rawType;
|
||||||
|
if (Array.isArray(rawType)) {
|
||||||
|
const nonNullType = rawType.find((t) => t !== "null");
|
||||||
|
if (typeof nonNullType === "string") return nonNullType;
|
||||||
|
}
|
||||||
|
if (isRecord(schema.properties) || isRecord(schema.additionalProperties)) return "object";
|
||||||
|
if (schema.items != null) return "array";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function importAuthentication({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
spec,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
spec: UnknownRecord;
|
||||||
|
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
|
||||||
|
const security = operation.security ?? spec.security;
|
||||||
|
if (!Array.isArray(security) || security.length === 0) {
|
||||||
|
return { authenticationType: null, authentication: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemes = {
|
||||||
|
...toRecord(toRecord(spec.components).securitySchemes),
|
||||||
|
...toRecord(spec.securityDefinitions),
|
||||||
|
};
|
||||||
|
for (const requirement of security) {
|
||||||
|
for (const schemeName of Object.keys(toRecord(requirement))) {
|
||||||
|
const scheme = toRecord(importState.resolve(schemes[schemeName]));
|
||||||
|
const type = stringAt(scheme, "type");
|
||||||
|
if (type === "apiKey") {
|
||||||
|
return {
|
||||||
|
authenticationType: "apikey",
|
||||||
|
authentication: {
|
||||||
|
location: stringAt(scheme, "in") === "query" ? "query" : "header",
|
||||||
|
key: stringAt(scheme, "name") ?? schemeName,
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "basic") {
|
||||||
|
return {
|
||||||
|
authenticationType: "basic",
|
||||||
|
authentication: { username: "", password: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "bearer") {
|
||||||
|
return {
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: { token: "", prefix: "Bearer" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticationType: null, authentication: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
|
||||||
|
const headers: HttpRequestHeader[] = [];
|
||||||
|
for (const header of headerGroups.flat()) {
|
||||||
|
const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
||||||
|
if (existing == null) {
|
||||||
|
headers.push(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBodyText(example: unknown): string {
|
||||||
|
return typeof example === "string" ? example : JSON.stringify(example, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyExampleValue(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstExampleValue(examples: unknown): unknown {
|
||||||
|
const firstExample = Object.values(toRecord(examples))[0];
|
||||||
|
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
|
||||||
|
return firstExample;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPresent(...values: unknown[]): unknown {
|
||||||
|
return values.find((value) => value !== undefined && value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringAt(record: unknown, key: string): string | undefined {
|
||||||
|
const value = toRecord(record)[key];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): UnknownRecord {
|
||||||
|
return isRecord(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPresent<T>(value: T | null | undefined): value is T {
|
||||||
|
return value != null && value !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively render all nested object properties */
|
||||||
|
function convertTemplateSyntax<T>(obj: T): T {
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
|
||||||
|
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj) && obj != null) {
|
||||||
|
return obj.map(convertTemplateSyntax) as T;
|
||||||
|
}
|
||||||
|
if (typeof obj === "object" && obj != null) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUndefinedAttrs<T>(obj: T): T {
|
||||||
|
if (Array.isArray(obj) && obj != null) {
|
||||||
|
return obj.map(deleteUndefinedAttrs) as T;
|
||||||
|
}
|
||||||
|
if (typeof obj === "object" && obj != null) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj)
|
||||||
|
.filter(([, v]) => v !== undefined)
|
||||||
|
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportState {
|
||||||
|
readonly #spec: UnknownRecord;
|
||||||
|
readonly #idCount: Partial<Record<string, number>> = {};
|
||||||
|
#sortPriority = 0;
|
||||||
|
|
||||||
|
constructor(spec: UnknownRecord) {
|
||||||
|
this.#spec = spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateId(model: string): string {
|
||||||
|
this.#idCount[model] = (this.#idCount[model] ?? -1) + 1;
|
||||||
|
return `GENERATE_ID::${model.toUpperCase()}_${this.#idCount[model]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSortPriority(): number {
|
||||||
|
return this.#sortPriority++;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value: unknown, visitedRefs = new Set<string>()): unknown {
|
||||||
|
if (!isRecord(value) || typeof value.$ref !== "string") return value;
|
||||||
|
if (visitedRefs.has(value.$ref)) return {};
|
||||||
|
|
||||||
|
const nextVisitedRefs = new Set(visitedRefs);
|
||||||
|
nextVisitedRefs.add(value.$ref);
|
||||||
|
|
||||||
|
if (!value.$ref.startsWith("#/")) return value;
|
||||||
|
|
||||||
|
const resolved = value.$ref
|
||||||
|
.slice(2)
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||||
|
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
|
||||||
|
|
||||||
|
return this.resolve(resolved, nextVisitedRefs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { convertPostman } from "@yaak/importer-postman/src";
|
||||||
|
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
||||||
|
import { convert } from "openapi-to-postmanv2";
|
||||||
|
|
||||||
|
export async function convertOpenApiWithPostman(
|
||||||
|
contents: string,
|
||||||
|
): Promise<ImportPluginResponse | undefined> {
|
||||||
|
// oxlint-disable-next-line no-explicit-any
|
||||||
|
let postmanCollection: any;
|
||||||
|
try {
|
||||||
|
postmanCollection = await new Promise((resolve, reject) => {
|
||||||
|
// oxlint-disable-next-line no-explicit-any
|
||||||
|
convert({ type: "string", data: contents }, {}, (err, result: any) => {
|
||||||
|
if (err != null) reject(err);
|
||||||
|
|
||||||
|
if (Array.isArray(result.output) && result.output.length > 0) {
|
||||||
|
resolve(result.output[0].data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertPostman(JSON.stringify(postmanCollection));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
# Real-World OpenAPI Fixtures
|
||||||
|
|
||||||
|
These fixtures were copied from the public APIs.guru OpenAPI directory:
|
||||||
|
|
||||||
|
- `apis-guru.yaml`: https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.yaml
|
||||||
|
- `httpbin.yaml`: https://api.apis.guru/v2/specs/httpbin.org/0.9.2/openapi.yaml
|
||||||
|
- `nasa-apod.yaml`: https://api.apis.guru/v2/specs/nasa.gov/apod/1.0.0/openapi.yaml
|
||||||
|
- `xkcd.yaml`: https://api.apis.guru/v2/specs/xkcd.com/1.0.0/openapi.yaml
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://api.apis.guru/v2
|
||||||
|
info:
|
||||||
|
contact:
|
||||||
|
email: mike.ralphson@gmail.com
|
||||||
|
name: APIs.guru
|
||||||
|
url: https://APIs.guru
|
||||||
|
description: |
|
||||||
|
Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
|
||||||
|
**Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA).
|
||||||
|
Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui)
|
||||||
|
license:
|
||||||
|
name: CC0 1.0
|
||||||
|
url: https://github.com/APIs-guru/openapi-directory#licenses
|
||||||
|
title: APIs.guru
|
||||||
|
version: 2.2.0
|
||||||
|
x-apisguru-categories:
|
||||||
|
- open_data
|
||||||
|
- developer_tools
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_branding_logo_vertical.svg
|
||||||
|
x-origin:
|
||||||
|
- format: openapi
|
||||||
|
url: https://api.apis.guru/v2/openapi.yaml
|
||||||
|
version: "3.0"
|
||||||
|
x-providerName: apis.guru
|
||||||
|
x-tags:
|
||||||
|
- API
|
||||||
|
- Catalog
|
||||||
|
- Directory
|
||||||
|
- REST
|
||||||
|
- Swagger
|
||||||
|
- OpenAPI
|
||||||
|
externalDocs:
|
||||||
|
url: https://github.com/APIs-guru/openapi-directory/blob/master/API.md
|
||||||
|
security: []
|
||||||
|
tags:
|
||||||
|
- description: Actions relating to APIs in the collection
|
||||||
|
name: APIs
|
||||||
|
paths:
|
||||||
|
/list.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all APIs in the directory.
|
||||||
|
Returns links to the OpenAPI definitions for each API in the directory.
|
||||||
|
If API exist in multiple versions `preferred` one is explicitly marked.
|
||||||
|
Some basic info from the OpenAPI definition is cached inside each object.
|
||||||
|
This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API.
|
||||||
|
operationId: listAPIs
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/APIs"
|
||||||
|
description: OK
|
||||||
|
summary: List all APIs
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
/metrics.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
Some basic metrics for the entire directory.
|
||||||
|
Just stunning numbers to put on a front page and are intended purely for WoW effect :)
|
||||||
|
operationId: getMetrics
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Metrics"
|
||||||
|
description: OK
|
||||||
|
summary: Get basic metrics
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
/providers.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all the providers in the directory
|
||||||
|
operationId: getProviders
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
summary: List all providers
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/specs/{provider}/{api}.json":
|
||||||
|
get:
|
||||||
|
description: Returns the API entry for one specific version of an API where there is no serviceName.
|
||||||
|
operationId: getAPI
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
- $ref: "#/components/parameters/api"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/API"
|
||||||
|
description: OK
|
||||||
|
summary: Retrieve one version of a particular API
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/specs/{provider}/{service}/{api}.json":
|
||||||
|
get:
|
||||||
|
description: Returns the API entry for one specific version of an API where there is a serviceName.
|
||||||
|
operationId: getServiceAPI
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
- in: path
|
||||||
|
name: service
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
example: graph
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
- $ref: "#/components/parameters/api"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/API"
|
||||||
|
description: OK
|
||||||
|
summary: Retrieve one version of a particular API with a serviceName.
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/{provider}.json":
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all APIs in the directory for a particular providerName
|
||||||
|
Returns links to the individual API entry for each API.
|
||||||
|
operationId: getProvider
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/APIs"
|
||||||
|
description: OK
|
||||||
|
summary: List all APIs for a particular provider
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/{provider}/services.json":
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all serviceNames in the directory for a particular providerName
|
||||||
|
operationId: getServices
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
minLength: 0
|
||||||
|
type: string
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
summary: List all serviceNames for a particular provider
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
api:
|
||||||
|
in: path
|
||||||
|
name: api
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
example: 2.1.0
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
in: path
|
||||||
|
name: provider
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
example: apis.guru
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
schemas:
|
||||||
|
API:
|
||||||
|
additionalProperties: false
|
||||||
|
description: Meta information about API
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
description: Timestamp when the API was first added to the directory
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
preferred:
|
||||||
|
description: Recommended version
|
||||||
|
type: string
|
||||||
|
versions:
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/components/schemas/ApiVersion"
|
||||||
|
description: List of supported versions of the API
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- added
|
||||||
|
- preferred
|
||||||
|
- versions
|
||||||
|
type: object
|
||||||
|
APIs:
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/components/schemas/API"
|
||||||
|
description: |
|
||||||
|
List of API details.
|
||||||
|
It is a JSON object with API IDs(`<provider>[:<service>]`) as keys.
|
||||||
|
example:
|
||||||
|
googleapis.com:drive:
|
||||||
|
added: 2015-02-22T20:00:45.000Z
|
||||||
|
preferred: v3
|
||||||
|
versions:
|
||||||
|
v2:
|
||||||
|
added: 2015-02-22T20:00:45.000Z
|
||||||
|
info:
|
||||||
|
title: Drive
|
||||||
|
version: v2
|
||||||
|
x-apiClientRegistration:
|
||||||
|
url: https://console.developers.google.com
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
|
||||||
|
x-origin:
|
||||||
|
format: google
|
||||||
|
url: https://www.googleapis.com/discovery/v1/apis/drive/v2/rest
|
||||||
|
version: v1
|
||||||
|
x-preferred: false
|
||||||
|
x-providerName: googleapis.com
|
||||||
|
x-serviceName: drive
|
||||||
|
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.json
|
||||||
|
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.yaml
|
||||||
|
updated: 2016-06-17T00:21:44.000Z
|
||||||
|
v3:
|
||||||
|
added: 2015-12-12T00:25:13.000Z
|
||||||
|
info:
|
||||||
|
title: Drive
|
||||||
|
version: v3
|
||||||
|
x-apiClientRegistration:
|
||||||
|
url: https://console.developers.google.com
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
|
||||||
|
x-origin:
|
||||||
|
format: google
|
||||||
|
url: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest
|
||||||
|
version: v1
|
||||||
|
x-preferred: true
|
||||||
|
x-providerName: googleapis.com
|
||||||
|
x-serviceName: drive
|
||||||
|
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.json
|
||||||
|
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.yaml
|
||||||
|
updated: 2016-06-17T00:21:44.000Z
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
ApiVersion:
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
description: Timestamp when the version was added
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
externalDocs:
|
||||||
|
description: Copy of `externalDocs` section from OpenAPI definition
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
info:
|
||||||
|
description: Copy of `info` section from OpenAPI definition
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
link:
|
||||||
|
description: Link to the individual API entry for this API
|
||||||
|
format: url
|
||||||
|
type: string
|
||||||
|
openapiVer:
|
||||||
|
description: The value of the `openapi` or `swagger` property of the source definition
|
||||||
|
type: string
|
||||||
|
swaggerUrl:
|
||||||
|
description: URL to OpenAPI definition in JSON format
|
||||||
|
format: url
|
||||||
|
type: string
|
||||||
|
swaggerYamlUrl:
|
||||||
|
description: URL to OpenAPI definition in YAML format
|
||||||
|
format: url
|
||||||
|
type: string
|
||||||
|
updated:
|
||||||
|
description: Timestamp when the version was updated
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- added
|
||||||
|
- updated
|
||||||
|
- swaggerUrl
|
||||||
|
- swaggerYamlUrl
|
||||||
|
- info
|
||||||
|
- openapiVer
|
||||||
|
type: object
|
||||||
|
Metrics:
|
||||||
|
additionalProperties: false
|
||||||
|
description: List of basic metrics
|
||||||
|
example:
|
||||||
|
datasets: []
|
||||||
|
fixedPct: 22
|
||||||
|
fixes: 81119
|
||||||
|
invalid: 598
|
||||||
|
issues: 28
|
||||||
|
numAPIs: 2501
|
||||||
|
numDrivers: 10
|
||||||
|
numEndpoints: 106448
|
||||||
|
numProviders: 659
|
||||||
|
numSpecs: 3329
|
||||||
|
stars: 2429
|
||||||
|
thisWeek:
|
||||||
|
added: 45
|
||||||
|
updated: 171
|
||||||
|
unofficial: 25
|
||||||
|
unreachable: 123
|
||||||
|
properties:
|
||||||
|
datasets:
|
||||||
|
description: Data used for charting etc
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
fixedPct:
|
||||||
|
description: Percentage of all APIs where auto fixes have been applied
|
||||||
|
type: integer
|
||||||
|
fixes:
|
||||||
|
description: Total number of fixes applied across all APIs
|
||||||
|
type: integer
|
||||||
|
invalid:
|
||||||
|
description: Number of newly invalid APIs
|
||||||
|
type: integer
|
||||||
|
issues:
|
||||||
|
description: Open GitHub issues on our main repo
|
||||||
|
type: integer
|
||||||
|
numAPIs:
|
||||||
|
description: Number of unique APIs
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
numDrivers:
|
||||||
|
description: Number of methods of API retrieval
|
||||||
|
type: integer
|
||||||
|
numEndpoints:
|
||||||
|
description: Total number of endpoints inside all definitions
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
numProviders:
|
||||||
|
description: Number of API providers in directory
|
||||||
|
type: integer
|
||||||
|
numSpecs:
|
||||||
|
description: Number of API definitions including different versions of the same API
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
stars:
|
||||||
|
description: GitHub stars for our main repo
|
||||||
|
type: integer
|
||||||
|
thisWeek:
|
||||||
|
description: Summary totals for the last 7 days
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
description: APIs added in the last week
|
||||||
|
type: integer
|
||||||
|
updated:
|
||||||
|
description: APIs updated in the last week
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
unofficial:
|
||||||
|
description: Number of unofficial APIs
|
||||||
|
type: integer
|
||||||
|
unreachable:
|
||||||
|
description: Number of unreachable (4XX,5XX status) APIs
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- numSpecs
|
||||||
|
- numAPIs
|
||||||
|
- numEndpoints
|
||||||
|
type: object
|
||||||
|
x-optic-standard: "@febf8ac6-ee67-4565-b45a-5c85a469dca7/Fz6KU3_wMIO5iJ6_VUZ30"
|
||||||
|
x-optic-url: https://app.useoptic.com/organizations/febf8ac6-ee67-4565-b45a-5c85a469dca7/apis/_0fKWqUvhs9ssYNkq1k-c
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://api.nasa.gov/planetary
|
||||||
|
- url: http://api.nasa.gov/planetary
|
||||||
|
info:
|
||||||
|
contact:
|
||||||
|
email: evan.t.yates@nasa.gov
|
||||||
|
description: This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery
|
||||||
|
license:
|
||||||
|
name: Apache 2.0
|
||||||
|
url: http://www.apache.org/licenses/LICENSE-2.0.html
|
||||||
|
title: APOD
|
||||||
|
version: 1.0.0
|
||||||
|
x-apisguru-categories:
|
||||||
|
- media
|
||||||
|
- open_data
|
||||||
|
x-origin:
|
||||||
|
- format: swagger
|
||||||
|
url: https://raw.githubusercontent.com/nasa/api-docs/gh-pages/assets/json/APOD
|
||||||
|
version: "2.0"
|
||||||
|
x-providerName: nasa.gov
|
||||||
|
x-serviceName: apod
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_assets_images_no-logo.svg
|
||||||
|
tags:
|
||||||
|
- description: An example tag
|
||||||
|
externalDocs:
|
||||||
|
description: Here's a link
|
||||||
|
url: https://example.com
|
||||||
|
name: request tag
|
||||||
|
paths:
|
||||||
|
/apod:
|
||||||
|
get:
|
||||||
|
description: Returns the picture of the day
|
||||||
|
parameters:
|
||||||
|
- description: The date of the APOD image to retrieve
|
||||||
|
in: query
|
||||||
|
name: date
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Retrieve the URL for the high resolution image
|
||||||
|
in: query
|
||||||
|
name: hd
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
x-thing: ok
|
||||||
|
type: array
|
||||||
|
description: successful operation
|
||||||
|
"400":
|
||||||
|
description: Date must be between Jun 16, 1995 and Mar 28, 2019.
|
||||||
|
security:
|
||||||
|
- api_key: []
|
||||||
|
summary: Returns images
|
||||||
|
tags:
|
||||||
|
- request tag
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
api_key:
|
||||||
|
in: query
|
||||||
|
name: api_key
|
||||||
|
type: apiKey
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: http://xkcd.com/
|
||||||
|
info:
|
||||||
|
description: Webcomic of romance, sarcasm, math, and language.
|
||||||
|
title: XKCD
|
||||||
|
version: 1.0.0
|
||||||
|
x-apisguru-categories:
|
||||||
|
- media
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/http_imgs.xkcd.com_static_terrible_small_logo.png
|
||||||
|
x-origin:
|
||||||
|
- format: openapi
|
||||||
|
url: https://raw.githubusercontent.com/APIs-guru/unofficial_openapi_specs/master/xkcd.com/1.0.0/openapi.yaml
|
||||||
|
version: "3.0"
|
||||||
|
x-providerName: xkcd.com
|
||||||
|
x-tags:
|
||||||
|
- humor
|
||||||
|
- comics
|
||||||
|
x-unofficialSpec: true
|
||||||
|
externalDocs:
|
||||||
|
url: https://xkcd.com/json.html
|
||||||
|
paths:
|
||||||
|
/info.0.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
Fetch current comic and metadata.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
"*/*":
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/comic"
|
||||||
|
description: OK
|
||||||
|
"/{comicId}/info.0.json":
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
Fetch comics and metadata by comic id.
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: comicId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
"*/*":
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/comic"
|
||||||
|
description: OK
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
comic:
|
||||||
|
properties:
|
||||||
|
alt:
|
||||||
|
type: string
|
||||||
|
day:
|
||||||
|
type: string
|
||||||
|
img:
|
||||||
|
type: string
|
||||||
|
link:
|
||||||
|
type: string
|
||||||
|
month:
|
||||||
|
type: string
|
||||||
|
news:
|
||||||
|
type: string
|
||||||
|
num:
|
||||||
|
type: number
|
||||||
|
safe_title:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
transcript:
|
||||||
|
type: string
|
||||||
|
year:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
@@ -5,7 +5,13 @@ import { convertOpenApi } from "../src";
|
|||||||
|
|
||||||
describe("importer-openapi", () => {
|
describe("importer-openapi", () => {
|
||||||
const p = path.join(__dirname, "fixtures");
|
const p = path.join(__dirname, "fixtures");
|
||||||
const fixtures = fs.readdirSync(p);
|
const fixtures = fs.readdirSync(p).filter((fixture) => {
|
||||||
|
return fs.statSync(path.join(p, fixture)).isFile();
|
||||||
|
});
|
||||||
|
const realWorldFixturesPath = path.join(p, "real-world");
|
||||||
|
const realWorldFixtures = fs
|
||||||
|
.readdirSync(realWorldFixturesPath)
|
||||||
|
.filter((fixture) => fixture.endsWith(".yaml"));
|
||||||
|
|
||||||
test("Maps operation description to request description", async () => {
|
test("Maps operation description to request description", async () => {
|
||||||
const imported = await convertOpenApi(
|
const imported = await convertOpenApi(
|
||||||
@@ -25,7 +31,195 @@ describe("importer-openapi", () => {
|
|||||||
|
|
||||||
expect(imported?.resources.httpRequests).toEqual([
|
expect(imported?.resources.httpRequests).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
description: "Lijst van klanten",
|
description: expect.stringContaining("Lijst van klanten"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Imports requests directly from OpenAPI details", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: { title: "Native Import Test", version: "1.0.0" },
|
||||||
|
servers: [
|
||||||
|
{ url: "https://api.example.com/{version}", variables: { version: { default: "v1" } } },
|
||||||
|
],
|
||||||
|
tags: [{ name: "accounts", description: "Account operations" }],
|
||||||
|
paths: {
|
||||||
|
"/accounts/{accountId}/members": {
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "accountId",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
description: "Account identifier",
|
||||||
|
schema: { type: "string", example: "acct_123" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
post: {
|
||||||
|
tags: ["accounts"],
|
||||||
|
summary: "Create member",
|
||||||
|
operationId: "createMember",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "include",
|
||||||
|
in: "query",
|
||||||
|
description: "Related resources to include",
|
||||||
|
schema: { type: "string", enum: ["roles"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Trace-Id",
|
||||||
|
in: "header",
|
||||||
|
schema: { type: "string", example: "trace-123" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
security: [{ tokenAuth: [] }],
|
||||||
|
requestBody: {
|
||||||
|
description: "Member payload",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/MemberInput" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"201": { description: "Created" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
tokenAuth: { type: "http", scheme: "bearer" },
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
MemberInput: {
|
||||||
|
type: "object",
|
||||||
|
required: ["email"],
|
||||||
|
properties: {
|
||||||
|
email: { type: "string", example: "me@example.com" },
|
||||||
|
admin: { type: "boolean", default: false },
|
||||||
|
primaryContact: { $ref: "#/components/schemas/Contact" },
|
||||||
|
secondaryContact: { $ref: "#/components/schemas/Contact" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Contact: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", example: "Taylor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.folders).toEqual([
|
||||||
|
expect.objectContaining({ name: "accounts", description: "Account operations" }),
|
||||||
|
]);
|
||||||
|
expect(imported?.resources.environments).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Global Variables",
|
||||||
|
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(imported?.resources.httpRequests).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Create member",
|
||||||
|
method: "POST",
|
||||||
|
url: "${[baseUrl]}/accounts/:accountId/members",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: { token: "", prefix: "Bearer" },
|
||||||
|
bodyType: "application/json",
|
||||||
|
body: {
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
email: "me@example.com",
|
||||||
|
admin: false,
|
||||||
|
primaryContact: { name: "Taylor" },
|
||||||
|
secondaryContact: { name: "Taylor" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
headers: expect.arrayContaining([
|
||||||
|
{ enabled: false, name: "X-Trace-Id", value: "trace-123" },
|
||||||
|
{ enabled: true, name: "Content-Type", value: "application/json" },
|
||||||
|
]),
|
||||||
|
urlParameters: [
|
||||||
|
{ enabled: true, name: ":accountId", value: "acct_123" },
|
||||||
|
{ enabled: false, name: "include", value: "roles" },
|
||||||
|
],
|
||||||
|
description: expect.stringContaining("Operation ID: createMember"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(imported?.resources.httpRequests[0]?.description).toContain("Member payload");
|
||||||
|
expect(imported?.resources.httpRequests[0]?.description).toContain("201: Created");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Handles large schemas without the Postman converter path", async () => {
|
||||||
|
const paths: Record<string, unknown> = {};
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
paths[`/zones/{zoneId}/resources/${i}`] = {
|
||||||
|
get: {
|
||||||
|
tags: ["zones"],
|
||||||
|
summary: `Read resource ${i}`,
|
||||||
|
parameters: [
|
||||||
|
{ name: "zoneId", in: "path", required: true, schema: { type: "string" } },
|
||||||
|
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "OK",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/Resource" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: { title: "Large API", version: "1.0.0" },
|
||||||
|
servers: [{ url: "https://api.example.com/client/v4" }],
|
||||||
|
tags: [{ name: "zones" }],
|
||||||
|
paths,
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Resource: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
name: { type: "string" },
|
||||||
|
metadata: { $ref: "#/components/schemas/Metadata" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
createdOn: { type: "string", format: "date-time" },
|
||||||
|
tags: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests.length).toBe(500);
|
||||||
|
expect(imported?.resources.httpRequests[499]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Read resource 499",
|
||||||
|
url: "${[baseUrl]}/zones/:zoneId/resources/499",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(imported?.resources.environments).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -46,7 +240,15 @@ describe("importer-openapi", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(imported?.resources.httpRequests.length).toBe(19);
|
expect(imported?.resources.httpRequests.length).toBe(19);
|
||||||
expect(imported?.resources.folders.length).toBe(7);
|
expect(imported?.resources.folders.map((f) => f.name)).toEqual(["pet", "store", "user"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fixture of realWorldFixtures) {
|
||||||
|
test(`Snapshots real-world fixture ${fixture}`, async () => {
|
||||||
|
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
|
||||||
|
const imported = await convertOpenApi(contents);
|
||||||
|
expect(imported).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { convertOpenApiWithPostman } from "../src/legacy";
|
||||||
|
|
||||||
|
describe("importer-openapi legacy converter", () => {
|
||||||
|
const realWorldFixturesPath = path.join(__dirname, "fixtures", "real-world");
|
||||||
|
const realWorldFixtures = fs
|
||||||
|
.readdirSync(realWorldFixturesPath)
|
||||||
|
.filter((fixture) => fixture.endsWith(".yaml"));
|
||||||
|
|
||||||
|
for (const fixture of realWorldFixtures) {
|
||||||
|
test(`Snapshots legacy Postman-converter output for ${fixture}`, async () => {
|
||||||
|
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
|
||||||
|
const imported = await convertOpenApiWithPostman(contents);
|
||||||
|
expect(imported).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -69,9 +69,6 @@ const config = JSON.stringify({
|
|||||||
const normalizedAdditionalArgs = [];
|
const normalizedAdditionalArgs = [];
|
||||||
for (let i = 0; i < additionalArgs.length; i++) {
|
for (let i = 0; i < additionalArgs.length; i++) {
|
||||||
const arg = additionalArgs[i];
|
const arg = additionalArgs[i];
|
||||||
if (arg === "--") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--config" && i + 1 < additionalArgs.length) {
|
if (arg === "--config" && i + 1 < additionalArgs.length) {
|
||||||
const value = additionalArgs[i + 1];
|
const value = additionalArgs[i + 1];
|
||||||
const isInlineJson = value.trimStart().startsWith("{");
|
const isInlineJson = value.trimStart().startsWith("{");
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const Downloader = require("nodejs-file-downloader");
|
|||||||
const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
|
const { rmSync, cpSync, mkdirSync, existsSync } = require("node:fs");
|
||||||
const { execSync } = require("node:child_process");
|
const { execSync } = require("node:child_process");
|
||||||
|
|
||||||
const NODE_VERSION = "v24.11.1";
|
const nodeVersionFile = path.join(__dirname, "..", "packages", "plugin-runtime", ".node-version");
|
||||||
|
const NODE_VERSION = `v${fs.readFileSync(nodeVersionFile, "utf8").trim().replace(/^v/, "")}`;
|
||||||
|
|
||||||
// `${process.platform}_${process.arch}`
|
// `${process.platform}_${process.arch}`
|
||||||
const MAC_ARM = "darwin_arm64";
|
const MAC_ARM = "darwin_arm64";
|
||||||
|
|||||||
Reference in New Issue
Block a user