Compare commits

..

12 Commits

Author SHA1 Message Date
Tom Moor 51cfc757ec fix: JS error when no integrations are connected 2025-05-10 22:01:15 -04:00
Tom Moor 32c1712fdc fix: Various cases that could leave file handles open on export (#9168)
* fix: Various cases that could leave file handles open on export

* Consolidate error handling
2025-05-10 17:48:24 -04:00
Tom Moor d392149860 fix: Non-integration plugins missing in settings (#9167)
Other minor refactors
2025-05-10 12:45:06 -04:00
Tom Moor 30108ebded chore: Move Zapier settings page to plugin (#9166) 2025-05-10 10:25:46 -04:00
Tom Moor d0bd2baa9f Add integrations page (#9155)
* update useSettings

* Integration page skeleton

* add descriptions

* update design

* Integration page style update

* clean up

* update integration card

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update integration icon size

Co-authored-by: Tom Moor <tom.moor@gmail.com>

* Update all integrations menu item

* update IntegrationCard to use the `Text` component

* update card status

* fix: Google analytics never shows as installed
fix: Styling tweaks
Move webhooks out of integrations

* Add breadcrumbs

* Add filtering

* refactor

* Add hover state, tweak descriptions

---------

Co-authored-by: Tess99854 <tesnimesb@gmail.com>
Co-authored-by: Mahmoud Mohammed Ali <ibn.el4ai5@gmail.com>
Co-authored-by: Mahmoud Ali <mahmoud.ali.khallaf@gmail.com>
2025-05-10 09:59:41 -04:00
Tom Moor fd984774d0 Add smart preloading of settings screens to reduce flicker (#9165) 2025-05-10 09:17:43 -04:00
Tom Moor e216c68f6d fix: CMD+F with in-app find interface open should open native find interface (#9153) 2025-05-08 21:40:01 -04:00
Tom Moor 2e2a8bcc94 fix: Allow searching for current user in collection permissions (#9154) 2025-05-08 22:15:16 +00:00
Tom Moor 245d14f905 fix: Upgrade KaTeX (#9151) 2025-05-08 00:40:50 +00:00
Tom Moor 8717d160ce fix: Backlinks are limited at 25 (#9150) 2025-05-07 20:36:56 -04:00
Tom Moor 587ba85cc9 fix: LaTeX blocks show vertical scrollbar (#9149) 2025-05-08 00:17:47 +00:00
Tom Moor 80bb1ce977 fix: ExportDocumentTreeTask needs documentStructure (#9148) 2025-05-07 23:42:59 +00:00
36 changed files with 487 additions and 146 deletions
+8 -2
View File
@@ -15,6 +15,7 @@ import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import InputSelectPermission from "~/components/InputSelectPermission";
import { createLazyComponent } from "~/components/LazyLoad";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useBoolean from "~/hooks/useBoolean";
@@ -22,7 +23,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
import useStores from "~/hooks/useStores";
import { EmptySelectValue } from "~/types";
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
const IconPicker = createLazyComponent(() => import("~/components/IconPicker"));
export interface FormData {
name: string;
@@ -88,6 +89,11 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
React.useEffect(() => {
void IconPicker.preload();
}, []);
React.useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
@@ -202,7 +208,7 @@ export const CollectionForm = observer(function CollectionForm_({
);
});
const StyledIconPicker = styled(IconPicker)`
const StyledIconPicker = styled(IconPicker.Component)`
margin-left: 4px;
margin-right: 4px;
`;
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
import lazyWithRetry from "~/utils/lazyWithRetry";
export interface LazyComponent<T extends React.ComponentType<any>> {
Component: React.LazyExoticComponent<T>;
preload: () => Promise<{ default: T }>;
}
interface LazyLoadOptions {
retries?: number;
interval?: number;
}
/**
* Creates a lazy-loaded component with preloading capability and automatic retries on failure.
*
* @param factory A function that returns a promise of a component (eg: () => import('./MyComponent'))
* @param options Optional configuration for retry behavior
* @returns An object containing the lazy Component and a preload function
*
* @example
* ```typescript
* const MyComponent = createLazyComponent(() => import('./MyComponent'));
*
* function App() {
* return (
* <Suspense fallback={<div>Loading...</div>}>
* <MyComponent.Component />
* </Suspense>
* );
* }
*
* // Preload when needed:
* MyComponent.preload();
* ```
*/
export function createLazyComponent<T extends React.ComponentType<any>>(
factory: () => Promise<{ default: T }>,
options: LazyLoadOptions = {}
): LazyComponent<T> {
const { retries, interval } = options;
return {
Component: lazyWithRetry(factory, retries, interval),
preload: factory,
};
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
import Flex from "~/components/Flex";
import { createLazyComponent } from "~/components/LazyLoad";
import NudeButton from "~/components/NudeButton";
import PlaceholderText from "~/components/PlaceholderText";
import Popover from "~/components/Popover";
@@ -12,7 +13,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import useWindowSize from "~/hooks/useWindowSize";
import Tooltip from "../Tooltip";
const EmojiPanel = React.lazy(
const EmojiPanel = createLazyComponent(
() => import("~/components/IconPicker/components/EmojiPanel")
);
@@ -104,6 +105,7 @@ const ReactionPicker: React.FC<Props> = ({
aria-label={t("Reaction picker")}
className={className}
onClick={handlePopoverButtonClick}
onMouseEnter={() => EmojiPanel.preload()}
size={size}
>
<ReactionIcon size={22} />
@@ -123,7 +125,7 @@ const ReactionPicker: React.FC<Props> = ({
{popover.visible && (
<React.Suspense fallback={<Placeholder />}>
<EventBoundary>
<EmojiPanel
<EmojiPanel.Component
height={300}
panelWidth={panelWidth}
query={query}
@@ -93,11 +93,13 @@ export const Suggestions = observer(
const suggestions = React.useMemo(() => {
const filtered: Suggestion[] = (
document
? users.notInDocument(document.id, query)
? users
.notInDocument(document.id, query)
.filter((u) => u.id !== user.id)
: collection
? users.notInCollection(collection.id, query)
: users.activeOrInvited
).filter((u) => !u.isSuspended && u.id !== user.id);
).filter((u) => !u.isSuspended);
if (isEmail(query)) {
filtered.push(getSuggestionForEmail(query));
+12 -3
View File
@@ -23,12 +23,20 @@ import ToggleButton from "./components/ToggleButton";
import Version from "./components/Version";
function SettingsSidebar() {
const { ui } = useStores();
const { ui, integrations } = useStores();
const { t } = useTranslation();
const history = useHistory();
const location = useLocation();
const configs = useSettingsConfig();
const groupedConfig = groupBy(configs, "group");
const groupedConfig = groupBy(
configs.filter((item) =>
item.group === "Integrations" && item.pluginId
? integrations.findByService(item.pluginId)
: true
),
"group"
);
const returnToApp = React.useCallback(() => {
history.push("/home");
@@ -63,8 +71,9 @@ function SettingsSidebar() {
<SidebarLink
key={item.path}
to={item.path}
onClickIntent={item.preload}
active={
item.path !== settingsPath()
item.path.startsWith(settingsPath("templates"))
? location.pathname.startsWith(item.path)
: undefined
}
+1
View File
@@ -41,6 +41,7 @@ function useKeyboardShortcuts({
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
+1 -2
View File
@@ -18,8 +18,7 @@ export default function useEmbeds(loadIfMissing = false) {
React.useEffect(() => {
async function fetchEmbedIntegrations() {
try {
await integrations.fetchPage({
limit: 100,
await integrations.fetchAll({
type: IntegrationType.Embed,
});
} catch (err) {
+51 -25
View File
@@ -13,20 +13,21 @@ import {
ImportIcon,
ShapesIcon,
Icon,
PlusIcon,
InternetIcon,
} from "outline-icons";
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import { Integrations } from "~/scenes/Settings/Integrations";
import { createLazyComponent as lazy } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { useComputed } from "./useComputed";
import useCurrentTeam from "./useCurrentTeam";
import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
import useStores from "./useStores";
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
@@ -43,30 +44,38 @@ const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
export type ConfigItem = {
name: string;
path: string;
icon: React.FC<ComponentProps<typeof Icon>>;
component: React.ComponentType;
description?: string;
preload?: () => void;
enabled: boolean;
group: string;
pluginId?: string;
};
const useSettingsConfig = () => {
const { integrations } = useStores();
const user = useCurrentUser();
const team = useCurrentTeam();
const can = usePolicy(team);
const { t } = useTranslation();
React.useEffect(() => {
void integrations.fetchAll();
}, [integrations]);
const config = useComputed(() => {
const items: ConfigItem[] = [
// Account
{
name: t("Profile"),
path: settingsPath(),
component: Profile,
component: Profile.Component,
preload: Profile.preload,
enabled: true,
group: t("Account"),
icon: ProfileIcon,
@@ -74,7 +83,8 @@ const useSettingsConfig = () => {
{
name: t("Preferences"),
path: settingsPath("preferences"),
component: Preferences,
component: Preferences.Component,
preload: Preferences.preload,
enabled: true,
group: t("Account"),
icon: SettingsIcon,
@@ -82,7 +92,8 @@ const useSettingsConfig = () => {
{
name: t("Notifications"),
path: settingsPath("notifications"),
component: Notifications,
component: Notifications.Component,
preload: Notifications.preload,
enabled: true,
group: t("Account"),
icon: EmailIcon,
@@ -90,7 +101,8 @@ const useSettingsConfig = () => {
{
name: t("API & Apps"),
path: settingsPath("api-and-apps"),
component: APIAndApps,
component: APIAndApps.Component,
preload: APIAndApps.preload,
enabled: true,
group: t("Account"),
icon: PadlockIcon,
@@ -99,7 +111,8 @@ const useSettingsConfig = () => {
{
name: t("Details"),
path: settingsPath("details"),
component: Details,
component: Details.Component,
preload: Details.preload,
enabled: can.update,
group: t("Workspace"),
icon: TeamIcon,
@@ -107,7 +120,8 @@ const useSettingsConfig = () => {
{
name: t("Security"),
path: settingsPath("security"),
component: Security,
component: Security.Component,
preload: Security.preload,
enabled: can.update,
group: t("Workspace"),
icon: PadlockIcon,
@@ -115,7 +129,8 @@ const useSettingsConfig = () => {
{
name: t("Features"),
path: settingsPath("features"),
component: Features,
component: Features.Component,
preload: Features.preload,
enabled: can.update,
group: t("Workspace"),
icon: BeakerIcon,
@@ -123,7 +138,8 @@ const useSettingsConfig = () => {
{
name: t("Members"),
path: settingsPath("members"),
component: Members,
component: Members.Component,
preload: Members.preload,
enabled: can.listUsers,
group: t("Workspace"),
icon: UserIcon,
@@ -131,7 +147,8 @@ const useSettingsConfig = () => {
{
name: t("Groups"),
path: settingsPath("groups"),
component: Groups,
component: Groups.Component,
preload: Groups.preload,
enabled: can.listGroups,
group: t("Workspace"),
icon: GroupIcon,
@@ -139,7 +156,8 @@ const useSettingsConfig = () => {
{
name: t("Templates"),
path: settingsPath("templates"),
component: Templates,
component: Templates.Component,
preload: Templates.preload,
enabled: can.readTemplate,
group: t("Workspace"),
icon: ShapesIcon,
@@ -147,7 +165,8 @@ const useSettingsConfig = () => {
{
name: t("API Keys"),
path: settingsPath("api-keys"),
component: ApiKeys,
component: ApiKeys.Component,
preload: ApiKeys.preload,
enabled: can.listApiKeys,
group: t("Workspace"),
icon: CodeIcon,
@@ -155,7 +174,8 @@ const useSettingsConfig = () => {
{
name: t("Applications"),
path: settingsPath("applications"),
component: Applications,
component: Applications.Component,
preload: Applications.preload,
enabled: can.listOAuthClients,
group: t("Workspace"),
icon: InternetIcon,
@@ -163,7 +183,8 @@ const useSettingsConfig = () => {
{
name: t("Shared Links"),
path: settingsPath("shares"),
component: Shares,
component: Shares.Component,
preload: Shares.preload,
enabled: can.listShares,
group: t("Workspace"),
icon: GlobeIcon,
@@ -171,7 +192,8 @@ const useSettingsConfig = () => {
{
name: t("Import"),
path: settingsPath("import"),
component: Import,
component: Import.Component,
preload: Import.preload,
enabled: can.createImport,
group: t("Workspace"),
icon: ImportIcon,
@@ -179,19 +201,20 @@ const useSettingsConfig = () => {
{
name: t("Export"),
path: settingsPath("export"),
component: Export,
component: Export.Component,
preload: Export.preload,
enabled: can.createExport,
group: t("Workspace"),
icon: ExportIcon,
},
// Integrations
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
component: Zapier,
enabled: can.update && isCloudHosted,
name: `${t("Install")}`,
path: settingsPath("integrations"),
component: Integrations,
enabled: true,
group: t("Integrations"),
icon: ZapierIcon,
icon: PlusIcon,
},
];
@@ -208,7 +231,10 @@ const useSettingsConfig = () => {
? integrationSettingsPath(plugin.id)
: settingsPath(plugin.id),
group: t(group),
component: plugin.value.component,
pluginId: plugin.id,
description: plugin.value.description,
component: plugin.value.component.Component,
preload: plugin.value.component.preload,
enabled: plugin.value.enabled
? plugin.value.enabled(team, user)
: can.update,
+72
View File
@@ -0,0 +1,72 @@
import groupBy from "lodash/groupBy";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useSettingsConfig from "~/hooks/useSettingsConfig";
import useStores from "~/hooks/useStores";
import { settingsPath } from "~/utils/routeHelpers";
import IntegrationCard from "./components/IntegrationCard";
import { StickyFilters } from "./components/StickyFilters";
export function Integrations() {
const { t } = useTranslation();
const { integrations } = useStores();
const items = useSettingsConfig();
const [query, setQuery] = React.useState("");
const handleQuery = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value);
};
const groupedItems = groupBy(
items.filter(
(item) =>
item.group === "Integrations" &&
item.enabled &&
item.path !== settingsPath("integrations") &&
item.name.toLowerCase().includes(query.toLowerCase())
),
(item) =>
item.pluginId && integrations.findByService(item.pluginId)
? "connected"
: "available"
);
return (
<Scene title={t("Integrations")}>
<Heading>{t("Integrations")}</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a variety of integrations with third-party services.
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleQuery}
/>
</StickyFilters>
<Cards gap={30} wrap>
{groupedItems.connected?.map((item) => (
<IntegrationCard key={item.path} integration={item} isConnected />
))}
{groupedItems.available?.map((item) => (
<IntegrationCard key={item.path} integration={item} />
))}
</Cards>
</Scene>
);
}
const Cards = styled(Flex)`
margin-top: 20px;
width: "100%";
`;
@@ -0,0 +1,93 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { ConfigItem } from "~/hooks/useSettingsConfig";
import Button from "../../../components/Button";
import Flex from "../../../components/Flex";
import Text from "../../../components/Text";
type Props = {
integration: ConfigItem;
isConnected?: boolean;
};
function IntegrationCard({ integration, isConnected }: Props) {
const { t } = useTranslation();
return (
<Card as={Link} to={integration.path}>
<Flex align="center" gap={8}>
<integration.icon size={48} />
<Flex auto column>
<Name>{integration.name}</Name>
{isConnected && <Status>{t("Connected")}</Status>}
</Flex>
<Button as="span" neutral>
{isConnected ? t("Configure") : t("Connect")}
</Button>
</Flex>
<Description>{integration.description}</Description>
</Card>
);
}
export default IntegrationCard;
const Card = styled.div`
display: flex;
flex-direction: column;
flex-grow: 1;
padding: 20px;
width: 300px;
background: ${s("background")};
border: 1px solid ${s("inputBorder")};
color: ${s("text")};
border-radius: 8px;
transition: box-shadow 200ms ease;
cursor: var(--pointer);
&:hover {
box-shadow: rgba(0, 0, 0, 0.08) 0px 2px 4px, rgba(0, 0, 0, 0.06) 0px 4px 8px;
}
`;
const Name = styled(Text)`
margin: 0;
font-size: 16px;
font-weight: 600;
color: ${s("text")};
${ellipsis()}
`;
const Description = styled(Text)`
margin: 8px 0 0;
font-size: 15px;
max-width: 100%;
color: ${s("textTertiary")};
`;
const Status = styled(Text).attrs({
type: "secondary",
size: "small",
as: "span",
})`
display: inline-flex;
align-items: center;
&::after {
content: "";
display: inline-block;
width: 17px;
height: 17px;
background: radial-gradient(
circle at center,
${s("accent")} 0 33%,
transparent 33%
);
border-radius: 50%;
}
`;
@@ -0,0 +1,33 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import Breadcrumb from "~/components/Breadcrumb";
import Scene from "~/components/Scene";
import { settingsPath } from "~/utils/routeHelpers";
export function IntegrationScene({
children,
...rest
}: React.ComponentProps<typeof Scene>) {
const { t } = useTranslation();
return (
<Scene
left={
<Breadcrumb
items={[
{
type: "route",
title: t("Integrations"),
icon: <SettingsIcon />,
to: settingsPath("integrations"),
},
]}
/>
}
{...rest}
>
{children}
</Scene>
);
}
+2 -7
View File
@@ -279,19 +279,14 @@ export default class DocumentsStore extends Store<Document> {
@action
fetchBacklinks = async (documentId: string): Promise<void> => {
const res = await client.post(`/documents.list`, {
const documents = await this.fetchAll({
backlinkDocumentId: documentId,
});
invariant(res?.data, "Document list not available");
const { data } = res;
runInAction("DocumentsStore#fetchBacklinks", () => {
data.forEach(this.add);
this.addPolicies(res.policies);
this.backlinks.set(
documentId,
data.map((doc: Partial<Document>) => doc.id)
documents.map((doc) => doc.id)
);
});
};
+6
View File
@@ -10,6 +10,12 @@ class IntegrationsStore extends Store<Integration> {
super(rootStore, Integration);
}
findByService(service: string) {
return this.orderedData.find(
(integration) => integration.service === service
);
}
@computed
get orderedData(): Integration[] {
return naturalSort(Array.from(this.data.values()), "name");
+5 -2
View File
@@ -3,6 +3,7 @@ import sortBy from "lodash/sortBy";
import { action, observable } from "mobx";
import Team from "~/models/Team";
import User from "~/models/User";
import { LazyComponent } from "~/components/LazyLoad";
import { useComputed } from "~/hooks/useComputed";
import Logger from "./Logger";
import isCloudHosted from "./isCloudHosted";
@@ -27,8 +28,10 @@ type PluginValueMap = {
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The settings screen somponent, should be lazy loaded. */
component: React.LazyExoticComponent<React.ComponentType>;
/** The lazy loaded settings screen component. */
component: LazyComponent<React.ComponentType>;
/** The description that will show on the plugins card. */
description?: string;
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
+1 -1
View File
@@ -141,7 +141,7 @@
"jsdom": "^22.1.0",
"jsonwebtoken": "^9.0.0",
"jszip": "^3.10.1",
"katex": "^0.16.21",
"katex": "^0.16.22",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
+3 -3
View File
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { IntegrationService } from "@shared/types";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import { AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -11,7 +12,6 @@ import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import PlaceholderText from "~/components/PlaceholderText";
import Scene from "~/components/Scene";
import TeamLogo from "~/components/TeamLogo";
import Text from "~/components/Text";
import Time from "~/components/Time";
@@ -38,7 +38,7 @@ function GitHub() {
}, [integrations]);
return (
<Scene title="GitHub" icon={<GitHubIcon />}>
<IntegrationScene title="GitHub" icon={<GitHubIcon />}>
<Heading>GitHub</Heading>
{error === "access_denied" && (
@@ -146,7 +146,7 @@ function GitHub() {
</Trans>
</Notice>
)}
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Connect your GitHub account to Outline to enable rich, realtime, issue and pull request previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+3 -9
View File
@@ -6,12 +6,12 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
@@ -40,12 +40,6 @@ function GoogleAnalytics() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({ measurementId: integration?.settings.measurementId });
}, [integration, reset]);
@@ -75,7 +69,7 @@ function GoogleAnalytics() {
);
return (
<Scene title={t("Google Analytics")} icon={<GoogleIcon />}>
<IntegrationScene title={t("Google Analytics")} icon={<GoogleIcon />}>
<Heading>{t("Google Analytics")}</Heading>
<Text as="p" type="secondary">
@@ -100,7 +94,7 @@ function GoogleAnalytics() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Measure adoption and engagement by sending view and event analytics directly to your GA4 dashboard.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+1 -1
View File
@@ -1,5 +1,5 @@
{
"id": "googleanalytics",
"id": "google-analytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
+4 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -10,7 +10,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Connect your Linear account to Outline to enable rich, realtime, issue previews inside documents.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+3 -9
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -42,12 +42,6 @@ function Matomo() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
measurementId: integration?.settings.measurementId,
@@ -82,7 +76,7 @@ function Matomo() {
);
return (
<Scene title="Matomo" icon={<Icon />}>
<IntegrationScene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
@@ -121,7 +115,7 @@ function Matomo() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Track your docs with a self-hosted, open-source analytics platform, link Outline to Matomo for 100% data ownership, GDPR compliance, and deep usage insights on your own servers.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+4 -10
View File
@@ -6,6 +6,7 @@ import { IntegrationService, IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
@@ -13,7 +14,6 @@ import CollectionIcon from "~/components/Icons/CollectionIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
@@ -34,13 +34,7 @@ function Slack() {
const error = query.get("error");
React.useEffect(() => {
void collections.fetchPage({
limit: 100,
});
void integrations.fetchPage({
service: IntegrationService.Slack,
limit: 100,
});
void collections.fetchAll();
}, [collections, integrations]);
const commandIntegration = integrations.find({
@@ -67,7 +61,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon />}>
<IntegrationScene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -205,7 +199,7 @@ function Slack() {
</List>
</>
)}
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Search your knowledge base directly in Slack, get /outline search, rich link previews, and notifications on new or updated docs.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) =>
[UserRole.Member, UserRole.Admin].includes(user.role),
},
+3 -9
View File
@@ -6,11 +6,11 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
@@ -44,12 +44,6 @@ function Umami() {
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
umamiWebsiteId: integration?.settings.measurementId,
@@ -85,7 +79,7 @@ function Umami() {
);
return (
<Scene title="Umami" icon={<Icon />}>
<IntegrationScene title="Umami" icon={<Icon />}>
<Heading>Umami</Heading>
<Text as="p" type="secondary">
@@ -145,7 +139,7 @@ function Umami() {
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</IntegrationScene>
);
}
+4 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { UserRole } from "@shared/types";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -11,7 +11,9 @@ PluginManager.add([
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Gain privacy-first insights into how your team consumes docs, inject your self-hosted Umami script across Outline pages to track views and engagement while retaining full control of your data.",
component: createLazyComponent(() => import("./Settings")),
enabled: (_, user) => user.role === UserRole.Admin,
},
},
+6 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
@@ -8,9 +8,12 @@ PluginManager.add([
...config,
type: Hook.Settings,
value: {
group: "Integrations",
group: "Workspace",
after: "Shared Links",
icon: Icon,
component: React.lazy(() => import("./Settings")),
description:
"Automate downstream workflows with real-time JSON POSTs, subscribe to events in Outline so external systems can react instantly.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
@@ -1,20 +1,22 @@
import * as React from "react";
import { Helmet } from "react-helmet-async";
import { Trans } from "react-i18next";
import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene";
import Heading from "~/components/Heading";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import ZapierIcon from "./Icon";
function Zapier() {
const { ui } = useStores();
const user = useCurrentUser();
const { resolvedTheme } = ui;
const appName = env.APP_NAME;
return (
<Scene title="Zapier" icon={<ZapierIcon />}>
<IntegrationScene title="Zapier" icon={<ZapierIcon />}>
<Heading>Zapier</Heading>
<Helmet>
<script
@@ -39,14 +41,15 @@ function Zapier() {
<zapier-app-directory
app="outline"
link-target="new-tab"
theme={resolvedTheme}
sign-up-email={user.email}
theme={resolvedTheme === "system" ? undefined : resolvedTheme}
hide="notion,confluence-cloud,confluence,google-docs,slack"
applimit={6}
introcopy="hide"
create-without-template="show"
use-this-zap="show"
/>
</Scene>
</IntegrationScene>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { createLazyComponent } from "~/components/LazyLoad";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
description:
"Connect your Outline workspace to Zapier to automate workflows and integrate with thousands of other tools.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "zapier",
"name": "Zapier",
"description": "Adds a settings screen for connecting to Zapier.",
"deployments": ["cloud"]
}
+54 -28
View File
@@ -41,7 +41,7 @@ export default class ZipHelper {
prefix: "export-",
postfix: ".zip",
},
(err, path) => {
(err, filePath) => {
if (err) {
return reject(err);
}
@@ -51,13 +51,24 @@ export default class ZipHelper {
currentFile: null,
};
const handleError = (error: Error) => {
dest.destroy();
fs.remove(filePath)
.catch((rmErr) => {
Logger.error("Failed to remove tmp file", rmErr);
})
.finally(() => {
reject(error);
});
};
const dest = fs
.createWriteStream(path)
.createWriteStream(filePath)
.on("finish", () => {
Logger.debug("utils", "Writing zip complete", { path });
return resolve(path);
Logger.debug("utils", "Writing zip complete", { path: filePath });
return resolve(filePath);
})
.on("error", reject);
.on("error", handleError);
zip
.generateNodeStream(
@@ -85,11 +96,9 @@ export default class ZipHelper {
}
}
)
.on("error", (rErr) => {
dest.end();
reject(rErr);
})
.pipe(dest);
.on("error", handleError)
.pipe(dest)
.on("error", handleError);
}
);
});
@@ -126,32 +135,38 @@ export default class ZipHelper {
const fileName = Buffer.from(entry.fileName).toString("utf8");
Logger.debug("utils", "Extracting zip entry", { fileName });
const processNext = (error?: NodeJS.ErrnoException | null) => {
if (error) {
zipfile.close();
reject(error);
return;
}
zipfile.readEntry();
};
if (validateFileName(fileName)) {
Logger.warn("Invalid zip entry", { fileName });
zipfile.readEntry();
} else if (/\/$/.test(fileName)) {
processNext();
return;
}
if (/\/$/.test(fileName)) {
// directory file names end with '/'
fs.mkdirp(
path.join(outputDir, fileName),
function (mErr: Error) {
if (mErr) {
return reject(mErr);
}
zipfile.readEntry();
}
fs.mkdirp(path.join(outputDir, fileName), (mkErr) =>
processNext(mkErr)
);
} else {
// file entry
zipfile.openReadStream(entry, function (rErr, readStream) {
if (rErr) {
return reject(rErr);
return processNext(rErr);
}
// ensure parent directory exists
fs.mkdirp(
path.join(outputDir, path.dirname(fileName)),
function (mkErr) {
if (mkErr) {
return reject(mkErr);
return processNext(mkErr);
}
const location = trimFileAndExt(
@@ -163,15 +178,20 @@ export default class ZipHelper {
);
const dest = fs
.createWriteStream(location)
.on("error", reject);
.on("error", (error) => {
readStream.destroy();
dest.destroy();
processNext(error);
});
readStream
.on("error", (rsErr) => {
dest.end();
reject(rsErr);
.on("error", (error) => {
dest.destroy();
readStream.destroy();
processNext(error);
})
.on("end", function () {
zipfile.readEntry();
processNext();
})
.pipe(dest);
}
@@ -180,8 +200,14 @@ export default class ZipHelper {
}
});
zipfile.on("close", resolve);
zipfile.on("error", reject);
zipfile.on("error", (error) => {
zipfile.close();
reject(error);
});
} catch (zErr) {
if (zipfile) {
zipfile.close();
}
reject(zErr);
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ const mathStyle = (props: Props) => css`
cursor: auto;
white-space: pre-wrap;
overflow-x: auto;
overflow-y: none;
overflow-y: hidden;
}
.math-node.empty-math .math-render::before {
+5 -2
View File
@@ -528,6 +528,7 @@
"Applications": "Applications",
"Shared Links": "Shared Links",
"Import": "Import",
"Install": "Install",
"Integrations": "Integrations",
"Revoke token": "Revoke token",
"Revoke": "Revoke",
@@ -974,6 +975,8 @@
"{{ count }} document imported_plural": "{{ count }} documents imported",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
"Configure": "Configure",
"Connect": "Connect",
"Last active": "Last active",
"Guest": "Guest",
"Never used": "Never used",
@@ -1034,6 +1037,7 @@
"Enterprise": "Enterprise",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.",
"Recent imports": "Recent imports",
"Configure a variety of integrations with third-party services.": "Configure a variety of integrations with third-party services.",
"Could not load members": "Could not load members",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
@@ -1118,7 +1122,6 @@
"You can create templates to help your team create consistent and accurate documentation.": "You can create templates to help your team create consistent and accurate documentation.",
"Alphabetical": "Alphabetical",
"There are no templates just yet.": "There are no templates just yet.",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
"A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.": "A confirmation code has been sent to your email address, please enter the code below to permanently destroy this workspace.",
"Confirmation code": "Confirmation code",
"Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.": "Deleting the <1>{{workspaceName}}</1> workspace will destroy all collections, documents, users, and associated data. You will be immediately logged out of {{appName}}.",
@@ -1144,7 +1147,6 @@
"Expires today": "Expires today",
"Expires tomorrow": "Expires tomorrow",
"Expires {{ date }}": "Expires {{ date }}",
"Connect": "Connect",
"Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?",
"Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.",
"The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.",
@@ -1215,6 +1217,7 @@
"Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.": "Webhooks can be used to notify your application when events happen in {{appName}}. Events are sent as a https request with a JSON payload in near real-time.",
"Inactive": "Inactive",
"Create a webhook": "Create a webhook",
"Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.": "Zapier is a platform that allows {{appName}} to easily integrate with thousands of other business tools. Automate your workflows, sync data, and more.",
"Never logged in": "Never logged in",
"Online now": "Online now",
"Online {{ timeAgo }}": "Online {{ timeAgo }}",
+8 -8
View File
@@ -737,7 +737,7 @@
"@jridgewell/trace-mapping" "^0.3.25"
jsesc "^3.0.2"
"@babel/helper-annotate-as-pure@^7.22.5", "@babel/helper-annotate-as-pure@^7.25.9", "@babel/helper-annotate-as-pure@^7.27.1":
"@babel/helper-annotate-as-pure@^7.22.5", "@babel/helper-annotate-as-pure@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz#4345d81a9a46a6486e24d069469f13e60445c05d"
integrity sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==
@@ -834,7 +834,7 @@
dependencies:
"@babel/types" "^7.27.1"
"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0":
"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c"
integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
@@ -1663,7 +1663,7 @@
"@babel/parser" "^7.27.1"
"@babel/types" "^7.27.1"
"@babel/traverse@^7.13.0", "@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.0":
"@babel/traverse@^7.13.0", "@babel/traverse@^7.27.1", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.0":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.1.tgz#4db772902b133bbddd1c4f7a7ee47761c1b9f291"
integrity sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==
@@ -1676,7 +1676,7 @@
debug "^4.3.1"
globals "^11.1.0"
"@babel/types@^7.0.0", "@babel/types@^7.25.9", "@babel/types@^7.27.1", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
"@babel/types@^7.0.0", "@babel/types@^7.27.1", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.1.tgz#9defc53c16fc899e46941fc6901a9eea1c9d8560"
integrity sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==
@@ -11162,10 +11162,10 @@ jws@^3.2.2:
jwa "^1.4.1"
safe-buffer "^5.0.1"
katex@^0.16.21, katex@^0.16.9:
version "0.16.21"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.21.tgz#8f63c659e931b210139691f2cc7bb35166b792a3"
integrity sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==
katex@^0.16.22, katex@^0.16.9:
version "0.16.22"
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.22.tgz#d2b3d66464b1e6d69e6463b28a86ced5a02c5ccd"
integrity sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==
dependencies:
commander "^8.3.0"