Compare commits

...

9 Commits

Author SHA1 Message Date
hmacr 9924a89d3a remove local oauth redirect 2025-03-07 01:32:44 +05:30
hmacr 992ae61432 create import 2025-03-07 01:30:42 +05:30
hmacr 5244dcd901 basic table UI 2025-03-07 00:01:12 +05:30
hmacr 6ed6b8711e list root pages in a dialog 2025-03-07 00:01:12 +05:30
hmacr 55088fb122 show oauth errors 2025-03-07 00:01:12 +05:30
hmacr 97e4c2dfd6 exchange code for access_token and persist 2025-03-07 00:01:12 +05:30
hmacr a9612f1e61 oauth redirect 2025-03-07 00:01:12 +05:30
hmacr 8da6b3fc8b existing notion importer as plugin 2025-03-07 00:01:12 +05:30
hmacr 38cd2f67eb config driven imports page 2025-03-07 00:01:12 +05:30
28 changed files with 946 additions and 82 deletions
+4
View File
@@ -147,6 +147,10 @@ DISCORD_SERVER_ID=
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––– IMPORTS ––––––––––––––
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
+4 -1
View File
@@ -23,7 +23,10 @@ function Dialogs() {
key={id}
isOpen={modal.isOpen}
fullscreen={modal.fullscreen ?? false}
onRequestClose={() => dialogs.closeModal(id)}
onRequestClose={() => {
modal.onClose?.();
dialogs.closeModal(id);
}}
title={modal.title}
style={modal.style}
>
+85 -79
View File
@@ -15,16 +15,90 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useStores from "~/hooks/useStores";
import { Hook, PluginManager } from "~/utils/PluginManager";
import FileOperationListItem from "./components/FileOperationListItem";
import ImportJSONDialog from "./components/ImportJSONDialog";
import ImportMarkdownDialog from "./components/ImportMarkdownDialog";
import ImportNotionDialog from "./components/ImportNotionDialog";
type Config = {
title: string;
subtitle: string;
icon: React.ReactElement;
action: React.ReactElement;
};
function Import() {
const { t } = useTranslation();
const { dialogs, fileOperations } = useStores();
const appName = env.APP_NAME;
const configs = React.useMemo(() => {
const items: Config[] = [
{
title: t("Markdown"),
subtitle: t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
),
icon: <MarkdownIcon size={28} />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
{
title: "JSON",
subtitle: t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
),
icon: <OutlineIcon size={28} cover />,
action: (
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
),
},
];
PluginManager.getHooks(Hook.Imports).forEach((plugin) => {
items.push({ ...plugin.value });
});
items.push({
title: "Confluence",
subtitle: t("Import pages from a Confluence instance"),
icon: <img src={cdnPath("/images/confluence.png")} width={28} />,
action: (
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
),
});
return items;
}, [t, dialogs, appName]);
return (
<Scene title={t("Import")} icon={<NewDocumentIcon />}>
<Heading>{t("Import")}</Heading>
@@ -38,84 +112,16 @@ function Import() {
</Text>
<div>
<Item
border={false}
image={<MarkdownIcon size={28} />}
title={t("Markdown")}
subtitle={t(
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)"
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportMarkdownDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<OutlineIcon size={28} cover />}
title="JSON"
subtitle={t(
"Import a JSON data file exported from another {{ appName }} instance",
{
appName,
}
)}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportJSONDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/notion.png")} width={28} />}
title="Notion"
subtitle={t("Import pages exported from Notion")}
actions={
<Button
type="submit"
onClick={() => {
dialogs.openModal({
title: t("Import data"),
content: <ImportNotionDialog />,
});
}}
neutral
>
{t("Import")}
</Button>
}
/>
<Item
border={false}
image={<img src={cdnPath("/images/confluence.png")} width={28} />}
title="Confluence"
subtitle={t("Import pages from a Confluence instance")}
actions={
<Button type="submit" disabled neutral>
{t("Enterprise")}
</Button>
}
/>
{configs.map((config) => (
<Item
key={config.title}
title={config.title}
subtitle={config.subtitle}
image={config.icon}
actions={config.action}
border={false}
/>
))}
</div>
<br />
<PaginatedList
+4
View File
@@ -8,6 +8,7 @@ type DialogDefinition = {
isOpen: boolean;
fullscreen?: boolean;
style?: React.CSSProperties;
onClose?: () => void;
};
export default class DialogsStore {
@@ -50,6 +51,7 @@ export default class DialogsStore {
fullscreen,
replace,
style,
onClose,
}: {
id?: string;
title: string;
@@ -57,6 +59,7 @@ export default class DialogsStore {
content: React.ReactNode;
style?: React.CSSProperties;
replace?: boolean;
onClose?: () => void;
}) => {
setTimeout(
action(() => {
@@ -70,6 +73,7 @@ export default class DialogsStore {
fullscreen,
style,
isOpen: true,
onClose,
});
}),
0
+7
View File
@@ -12,6 +12,7 @@ import isCloudHosted from "./isCloudHosted";
*/
export enum Hook {
Settings = "settings",
Imports = "imports",
Icon = "icon",
}
@@ -31,6 +32,12 @@ type PluginValueMap = {
/** Whether the plugin is enabled in the current context. */
enabled?: (team: Team, user: User) => boolean;
};
[Hook.Imports]: {
title: string;
subtitle: string;
icon: React.ReactElement;
action: React.ReactElement;
};
[Hook.Icon]: React.ElementType;
};
+1
View File
@@ -79,6 +79,7 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.2.16",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
+88
View File
@@ -0,0 +1,88 @@
import { observer } from "mobx-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import Button from "~/components/Button";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { redirectTo } from "~/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { ImportDialog } from "./components/ImportDialog";
export const Notion = observer(() => {
const { t } = useTranslation();
const { dialogs } = useStores();
const team = useCurrentTeam();
const history = useHistory();
const location = useLocation();
const queryParams = useQuery();
const appName = env.APP_NAME;
const authUrl = NotionUtils.authUrl({ state: { teamId: team.id } });
const service = queryParams.get("service");
const oauthSuccess = queryParams.get("success") === "";
const oauthError = queryParams.get("error");
const integrationId = queryParams.get("integrationId");
const clearQueryParams = React.useCallback(() => {
history.replace({
pathname: location.pathname,
search: "",
});
}, [history, location]);
const handleSubmit = React.useCallback(() => {
dialogs.closeAllModals();
clearQueryParams();
}, [dialogs, clearQueryParams]);
React.useEffect(() => {
if (
integrationId &&
oauthSuccess &&
service === IntegrationService.Notion
) {
dialogs.openModal({
title: t("Import data"),
content: (
<ImportDialog integrationId={integrationId} onSubmit={handleSubmit} />
),
onClose: clearQueryParams,
});
}
}, [t, dialogs, oauthSuccess, service, clearQueryParams]);
React.useEffect(() => {
if (!oauthError) {
return;
}
if (oauthError === "access_denied") {
toast.error(
t(
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
{
appName,
}
)
);
} else {
toast.error(
t(
"Something went wrong while authenticating your request. Please try logging in again."
)
);
}
}, [t, appName, oauthError]);
return (
<Button type="submit" onClick={() => redirectTo(authUrl)} neutral>
{t("Import")}
</Button>
);
});
@@ -0,0 +1,174 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import {
CollectionPermission,
ImportData,
IntegrationService,
} from "@shared/types";
import Button from "~/components/Button";
import { Emoji } from "~/components/Emoji";
import Flex from "~/components/Flex";
import InputSelectPermission from "~/components/InputSelectPermission";
import Text from "~/components/Text";
import useRequest from "~/hooks/useRequest";
import { EmptySelectValue } from "~/types";
import { client } from "~/utils/ApiClient";
import { Page } from "plugins/notion/shared/types";
type PageWithPermission = Page & {
permission?: CollectionPermission;
};
type Props = {
integrationId: string;
onSubmit: () => void;
};
export function ImportDialog({ integrationId, onSubmit }: Props) {
const { t } = useTranslation();
const [pagesWithPermission, setPagesWithPermission] =
React.useState<PageWithPermission[]>();
const loadRootPages = React.useCallback(async () => {
const res = await client.post("/notion.search", {
integrationId,
});
return res.data;
}, [integrationId]);
const {
data: pages,
error,
loading,
} = useRequest<Page[]>(loadRootPages, true);
const handlePermissionChange = React.useCallback(
(id: string, permission?: CollectionPermission) => {
setPagesWithPermission((prev) =>
prev?.map((page) => {
if (page.id === id) {
page.permission = permission;
}
return page;
})
);
},
[]
);
const handleStartImport = React.useCallback(async () => {
const data: ImportData = {
collection: pagesWithPermission!.map((page) => ({
externalId: page.id,
permission: page.permission,
})),
};
try {
await client.post("/imports.create", {
integrationId,
service: IntegrationService.Notion,
data,
});
toast.success(
t("Your import is being processed, you can safely leave this page")
);
onSubmit();
} catch (err) {
toast.error(err.message);
}
}, [pagesWithPermission, onSubmit]);
React.useEffect(() => {
if (pages?.length) {
setPagesWithPermission(pages);
} else {
setPagesWithPermission([]);
}
}, [pages]);
if (error) {
toast.error("Error while fetching page info from Notion");
return <div>Error: {error}</div>;
}
if (loading || !pagesWithPermission) {
return <div>Loading data</div>;
}
if (pagesWithPermission.length === 0) {
return <div>No pages available for import</div>;
}
return (
<Flex column gap={8}>
<div>
{pagesWithPermission.map((page) => (
<PageItem
key={page.id}
page={page}
onPermissionChange={handlePermissionChange}
/>
))}
</div>
<Flex justify="flex-end">
<Button onClick={handleStartImport}>{t("Start import")}</Button>
</Flex>
</Flex>
);
}
function PageItem({
page,
onPermissionChange,
}: {
page: PageWithPermission;
onPermissionChange: (id: string, permission?: CollectionPermission) => void;
}) {
const handlePermissionChange = React.useCallback(
(value: CollectionPermission | typeof EmptySelectValue) =>
onPermissionChange(
page.id,
value === EmptySelectValue ? undefined : value
),
[onPermissionChange]
);
return (
<Row align="center" justify="space-between" gap={4}>
<Column $width="75%" $border>
<Flex gap={6} align="center">
{page.emoji && <Emoji>{page.emoji}</Emoji>}
<Text size="small">{page.name}</Text>
</Flex>
</Column>
<Column>
<InputSelectPermission
onChange={handlePermissionChange}
value={page.permission}
labelHidden
nude
style={{ margin: 0 }}
/>
</Column>
</Row>
);
}
const Row = styled(Flex)`
border: 1px solid ${s("divider")};
border-collapse: collapse;
margin-bottom: auto;
`;
const Column = styled.div<{ $width?: string; $border?: boolean }>`
width: ${({ $width }) => $width || "auto"};
padding: 4px 6px;
border-right: 1px solid
${({ $border }) => ($border ? s("divider") : "transparent")};
`;
+19
View File
@@ -0,0 +1,19 @@
import { t } from "i18next";
import * as React from "react";
import { cdnPath } from "@shared/utils/urls";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import { Notion } from "./Imports";
PluginManager.add([
{
...config,
type: Hook.Imports,
value: {
title: "Notion",
subtitle: t("Import pages from Notion"),
icon: <img src={cdnPath("/images/notion.png")} width={28} />,
action: <Notion />,
},
},
]);
+5
View File
@@ -0,0 +1,5 @@
{
"id": "notion",
"name": "Notion",
"description": "Adds a Notion integration for importing data."
}
+135
View File
@@ -0,0 +1,135 @@
import Router from "koa-router";
import { IntegrationService, IntegrationType, UserRole } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import { InvalidRequestError } from "@server/errors";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Integration, IntegrationAuthentication, Team } from "@server/models";
import { can } from "@server/policies";
import { APIContext } from "@server/types";
import { NotionClient } from "../notion";
import { NotionOAuth } from "../oauth";
import * as T from "./schema";
import { NotionUtils } from "plugins/notion/shared/NotionUtils";
const router = new Router();
router.get(
"notion.callback",
auth({ optional: true }),
validate(T.NotionCallbackSchema),
transaction(),
async (ctx: APIContext<T.NotionCallbackReq>) => {
const { code, state, error } = ctx.input.query;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
let parsedState;
try {
parsedState = NotionUtils.parseState(state);
} catch {
ctx.redirect(NotionUtils.errorUrl("invalid_state"));
return;
}
const { teamId } = parsedState;
// This code block accounts for the root domain being unable to access authentication for subdomains.
// We must forward to the appropriate subdomain to complete the oauth flow.
if (!user) {
if (teamId) {
try {
const team = await Team.findByPk(teamId, {
rejectOnEmpty: true,
transaction,
});
return parseDomain(ctx.host).teamSubdomain === team.subdomain
? ctx.redirect("/")
: ctx.redirectOnClient(
NotionUtils.callbackUrl({
baseUrl: team.url,
params: ctx.request.querystring,
})
);
} catch (err) {
Logger.error(`Error fetching team for teamId: ${teamId}!`, err);
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
} else {
return ctx.redirect(NotionUtils.errorUrl("unauthenticated"));
}
}
// Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain.
if (error) {
ctx.redirect(NotionUtils.errorUrl(error));
return;
}
// validation middleware ensures that code is non-null at this point.
const data = await NotionOAuth.oauthAccess(code!);
const authentication = await IntegrationAuthentication.create(
{
service: IntegrationService.Notion,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
},
{ transaction }
);
const integration = await Integration.create(
{
service: IntegrationService.Notion,
type: IntegrationType.Import,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
externalWorkspace: {
id: data.workspace_id,
name: data.workspace_name ?? undefined,
iconUrl: data.workspace_icon ?? undefined,
},
},
},
{ transaction }
);
ctx.redirect(NotionUtils.successUrl(integration.id));
}
);
router.post(
"notion.search",
auth({ role: UserRole.Admin }),
validate(T.NotionSearchSchema),
async (ctx: APIContext<T.NotionSearchReq>) => {
const { integrationId } = ctx.input.body;
const { user } = ctx.state.auth;
const integration = await Integration.scope("withAuthentication").findByPk(
integrationId
);
can(user, "read", integration);
if (
integration?.service !== IntegrationService.Notion ||
integration.userId !== user.id
) {
throw InvalidRequestError("Invalid integrationId");
}
const notionClient = new NotionClient(integration.authentication.token);
const rootPages = await notionClient.fetchRootPages();
ctx.body = {
data: rootPages,
};
}
);
export default router;
+25
View File
@@ -0,0 +1,25 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
export const NotionCallbackSchema = BaseSchema.extend({
query: z
.object({
code: z.string().nullish(),
state: z.string(),
error: z.string().nullish(),
})
.refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), {
message: "one of code or error is required",
}),
});
export type NotionCallbackReq = z.infer<typeof NotionCallbackSchema>;
export const NotionSearchSchema = BaseSchema.extend({
body: z.object({
integrationId: z.string().uuid(),
}),
});
export type NotionSearchReq = z.infer<typeof NotionSearchSchema>;
+14
View File
@@ -0,0 +1,14 @@
import { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class NotionPluginEnvironment extends Environment {
@Public
public NOTION_CLIENT_ID = environment.NOTION_CLIENT_ID;
@CannotUseWithout("NOTION_CLIENT_ID")
public NOTION_CLIENT_SECRET = environment.NOTION_CLIENT_SECRET;
}
export default new NotionPluginEnvironment();
+16
View File
@@ -0,0 +1,16 @@
import { Hook, PluginManager } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./api/notion";
import env from "./env";
const enabled = !!env.NOTION_CLIENT_ID && !!env.NOTION_CLIENT_SECRET;
if (enabled) {
PluginManager.add([
{
...config,
type: Hook.API,
value: router,
},
]);
}
+47
View File
@@ -0,0 +1,47 @@
import {
Client,
isFullPageOrDatabase,
iteratePaginatedAPI,
} from "@notionhq/client";
import { Page, PageTitle } from "../shared/types";
export class NotionClient {
private client: Client;
private pageSize = 25;
constructor(accessToken: string) {
this.client = new Client({
auth: accessToken,
});
}
async fetchRootPages() {
const pages: Page[] = [];
for await (const item of iteratePaginatedAPI(this.client.search, {
page_size: this.pageSize,
})) {
if (!isFullPageOrDatabase(item)) {
continue;
}
if (item.parent.type === "workspace") {
let titleProp;
if (item.object === "page") {
titleProp = (item.properties["title"] as PageTitle).title;
} else {
titleProp = item.title;
}
pages.push({
id: item.id,
name: titleProp.at(0)?.plain_text ?? "",
emoji: item.icon?.type === "emoji" ? item.icon.emoji : undefined, // other icon types return a url to download from, which we don't support.
});
}
}
return pages;
}
}
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod";
import fetch from "@server/utils/fetch";
import { NotionUtils } from "../shared/NotionUtils";
import env from "./env";
export class NotionOAuth {
private static tokenUrl = "https://api.notion.com/v1/oauth/token";
private static credentials = Buffer.from(
`${env.NOTION_CLIENT_ID}:${env.NOTION_CLIENT_SECRET}`
).toString("base64");
private static resSchema = z.object({
access_token: z.string(),
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z.string().url().nullish(),
});
static async oauthAccess(code: string) {
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Basic ${this.credentials}`,
};
const body = {
grant_type: "authorization_code",
code,
redirect_uri: NotionUtils.callbackUrl(),
};
const res = await fetch(this.tokenUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
});
return this.resSchema.parse(await res.json());
}
}
+56
View File
@@ -0,0 +1,56 @@
import queryString from "query-string";
import env from "@shared/env";
import { IntegrationService } from "@shared/types";
import { settingsPath } from "@shared/utils/routeHelpers";
export type OAuthState = {
teamId: string;
};
export class NotionUtils {
private static authBaseUrl = "https://api.notion.com/v1/oauth/authorize";
private static settingsUrl = settingsPath("import");
static parseState(state: string): OAuthState {
return JSON.parse(state);
}
static successUrl(integrationId: string) {
const params = {
success: "",
service: IntegrationService.Notion,
integrationId,
};
return `${this.settingsUrl}?${queryString.stringify(params)}`;
}
static errorUrl(error: string) {
const params = {
error,
service: IntegrationService.Notion,
};
return `${this.settingsUrl}?${queryString.stringify(params)}`;
}
static callbackUrl(
{ baseUrl, params }: { baseUrl: string; params?: string } = {
baseUrl: `${env.URL}`,
params: undefined,
}
) {
return params
? `${baseUrl}/api/notion.callback?${params}`
: `${baseUrl}/api/notion.callback`;
}
static authUrl({ state }: { state: OAuthState }) {
const params = {
client_id: env.NOTION_CLIENT_ID,
redirect_uri: this.callbackUrl(),
state: JSON.stringify(state),
response_type: "code",
owner: "user",
};
return `${this.authBaseUrl}?${queryString.stringify(params)}`;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { RichTextItemResponse } from "@notionhq/client/build/src/api-endpoints";
export type Page = {
id: string;
name: string;
emoji?: string;
};
export type PageTitle = {
type: "title";
title: Array<RichTextItemResponse>;
};
@@ -0,0 +1,56 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.createTable("imports", {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
service: {
type: Sequelize.STRING,
allowNull: false,
},
state: {
type: Sequelize.STRING,
allowNull: false,
},
data: {
type: Sequelize.JSONB,
allowNull: false,
},
pendingTaskCount: {
type: Sequelize.INTEGER,
allowNull: false,
},
createdById: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
},
},
integrationId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "integrations",
},
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
},
async down(queryInterface, Sequelize) {
await queryInterface.dropTable("imports");
},
};
+59
View File
@@ -0,0 +1,59 @@
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
BelongsTo,
Column,
DataType,
Default,
ForeignKey,
IsIn,
Table,
} from "sequelize-typescript";
import {
type ImportData,
ImportState,
IntegrationService,
} from "@shared/types";
import Integration from "./Integration";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
@Table({ tableName: "imports", modelName: "import" })
@Fix
class Import<T = unknown> extends IdModel<
InferAttributes<Import<T>>,
Partial<InferCreationAttributes<Import<T>>>
> {
@IsIn([Object.values(IntegrationService)])
@Column(DataType.STRING)
service: IntegrationService;
@IsIn([Object.values(ImportState)])
@Column(DataType.STRING)
state: ImportState;
@Column(DataType.JSONB)
data: ImportData;
@Default(0)
@Column(DataType.INTEGER)
pendingTaskCount: number;
// associations
@BelongsTo(() => User, "createdById")
createdBy: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
createdById: string;
@BelongsTo(() => Integration, "integrationId")
integration: Integration;
@ForeignKey(() => Integration)
@Column(DataType.UUID)
integrationId: string;
}
export default Import;
+2
View File
@@ -24,6 +24,8 @@ export { default as Group } from "./Group";
export { default as GroupUser } from "./GroupUser";
export { default as Import } from "./Import";
export { default as Integration } from "./Integration";
export { default as IntegrationAuthentication } from "./IntegrationAuthentication";
+36
View File
@@ -0,0 +1,36 @@
import Router from "koa-router";
import { ImportState, UserRole } from "@shared/types";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import Import from "@server/models/Import";
import { authorize } from "@server/policies";
import { APIContext } from "@server/types";
import * as T from "./schema";
const router = new Router();
router.post(
"imports.create",
auth({ role: UserRole.Admin }),
validate(T.ImportsCreateSchema),
async (ctx: APIContext<T.ImportsCreateReq>) => {
const { integrationId, service, data } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "createImport", user.team);
await Import.create({
service,
state: ImportState.Created,
data,
createdById: user.id,
integrationId,
});
ctx.body = {
success: true,
};
}
);
export default router;
+1
View File
@@ -0,0 +1 @@
export { default } from "./imports";
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
import { CollectionPermission, IntegrationService } from "@shared/types";
import { BaseSchema } from "../schema";
export const ImportsCreateSchema = BaseSchema.extend({
body: z.object({
integrationId: z.string().uuid(),
service: z.nativeEnum(IntegrationService),
data: z.object({
collection: z.array(
z.object({
externalId: z.string(),
permission: z.nativeEnum(CollectionPermission).optional(),
})
),
}),
}),
});
export type ImportsCreateReq = z.infer<typeof ImportsCreateSchema>;
+2
View File
@@ -20,6 +20,7 @@ import events from "./events";
import fileOperationsRoute from "./fileOperations";
import groupMemberships from "./groupMemberships";
import groups from "./groups";
import imports from "./imports";
import installation from "./installation";
import integrations from "./integrations";
import apiErrorHandler from "./middlewares/apiErrorHandler";
@@ -97,6 +98,7 @@ router.use("/", fileOperationsRoute.routes());
router.use("/", urls.routes());
router.use("/", userMemberships.routes());
router.use("/", reactions.routes());
router.use("/", imports.routes());
if (!env.isCloudHosted) {
router.use("/", installation.routes());
+3 -2
View File
@@ -946,13 +946,12 @@
"New group": "New group",
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
"No groups have been created yet": "No groups have been created yet",
"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.",
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)",
"Import data": "Import data",
"Import a JSON data file exported from another {{ appName }} instance": "Import a JSON data file exported from another {{ appName }} instance",
"Import pages exported from Notion": "Import pages exported from Notion",
"Import pages from a Confluence instance": "Import pages from a Confluence instance",
"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",
"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.",
@@ -1087,6 +1086,8 @@
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/",
"Site ID": "Site ID",
"An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.",
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?": "Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
"Import pages from Notion": "Import pages from Notion",
"Add to Slack": "Add to Slack",
"document published": "document published",
"document updated": "document updated",
+15
View File
@@ -54,6 +54,18 @@ export enum FileOperationState {
Expired = "expired",
}
export enum ImportState {
Created = "created",
InProgress = "in_progress",
Processed = "processed",
Completed = "completed",
Errored = "errored",
}
export type ImportData = {
collection: { externalId: string; permission?: CollectionPermission }[];
};
export enum MentionType {
User = "user",
Document = "document",
@@ -86,6 +98,7 @@ export enum IntegrationType {
Analytics = "analytics",
/** An integration that maps an Outline user to an external service. */
LinkedAccount = "linkedAccount",
Import = "import",
}
export enum IntegrationService {
@@ -96,6 +109,7 @@ export enum IntegrationService {
Matomo = "matomo",
Umami = "umami",
GitHub = "github",
Notion = "notion",
}
export type UserCreatableIntegrationService = Extract<
@@ -157,6 +171,7 @@ export type IntegrationSettings<T> = T extends IntegrationType.Embed
| { serviceTeamId: string }
| { measurementId: string }
| { slack: { serviceTeamId: string; serviceUserId: string } }
| { externalWorkspace: { id: string; name?: string; iconUrl?: string } }
| undefined;
export enum UserPreference {
+16
View File
@@ -2812,6 +2812,14 @@
resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
"@notionhq/client@^2.2.16":
version "2.2.16"
resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-2.2.16.tgz#6564cd77bc12e7dc68e4f5c4c6adef8384a33027"
integrity sha512-3GlkfhLw8+Jw8U2iFEmHA6WfCgYhZCXLxgPdqDJkYMFotELNpQO+yGSy2QWURsG8ndu21sLt+FEOfDbNcCtFMg==
dependencies:
"@types/node-fetch" "^2.5.10"
node-fetch "^2.6.1"
"@octokit/app@^14.0.2":
version "14.0.2"
resolved "https://registry.yarnpkg.com/@octokit/app/-/app-14.0.2.tgz#b47c52020221351fb58640f113eb38b2ad3998fe"
@@ -5002,6 +5010,14 @@
resolved "https://registry.yarnpkg.com/@types/natural-sort/-/natural-sort-0.0.24.tgz#9a89fcbabd963937fab9cc4ca527635c2a7c0cef"
integrity "sha1-mon8ur2WOTf6ucxMpSdjXCp8DO8= sha512-+/F8JDyT0QUi2cE51S4Xsy4yuHVBiFsU5bq0g2XnTKkOj0Jz69o3TLYd1gnVTOrKOvg0+FUJqE9BA24FgnpuGg=="
"@types/node-fetch@^2.5.10":
version "2.6.12"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03"
integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==
dependencies:
"@types/node" "*"
form-data "^4.0.0"
"@types/node-fetch@^2.6.9":
version "2.6.9"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.9.tgz#15f529d247f1ede1824f7e7acdaa192d5f28071e"