mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecd4e9371f | |||
| 2b07f412e2 | |||
| 65bb3b11f3 | |||
| e1e334dd5f | |||
| 6e9092bcaf | |||
| 09a4b76aae | |||
| 5789d65bf5 | |||
| 03a0f54236 | |||
| 1e7244c737 | |||
| 96c41ce823 | |||
| 0702570b0d | |||
| 4b209a7913 | |||
| 6393bd02f4 | |||
| 1776aad833 | |||
| 0c6b37cb60 | |||
| d664044579 | |||
| b3ca434c51 | |||
| 631b75def4 | |||
| d183dab063 | |||
| f082da6456 | |||
| ad72210714 | |||
| 9c85b26d43 | |||
| bf6a56849e | |||
| 68e8b2791a | |||
| 89db519b72 | |||
| 31c412b4a6 | |||
| 199584428a | |||
| f22780e944 | |||
| a71381785c | |||
| a61b53aa74 | |||
| 45f0885533 | |||
| 9c9657f4cc | |||
| c3de0cf0ec | |||
| f7b00e72f1 | |||
| e499881110 | |||
| 016c8c802c | |||
| d4bc189e12 | |||
| 4d435cd5ec | |||
| 59c611b24f | |||
| 1ea40c03c5 | |||
| f9919e90cf | |||
| 0d09e54757 | |||
| 9ce7133837 | |||
| 01a5ff031a | |||
| 5659aeb360 | |||
| d300e34447 | |||
| a4040a93a2 | |||
| c769432993 | |||
| 270bb85417 | |||
| fe8e50da92 | |||
| 31d1f566bc | |||
| f9476770ce | |||
| 2e018e74b8 | |||
| a11ab56117 | |||
| 66e4ec32ed | |||
| bde9d5fbf4 | |||
| 70bb878a8c | |||
| 4237377d47 | |||
| a30f6b717b | |||
| 1edc23c5ae | |||
| ff6ec3a5b8 | |||
| 52c2729490 | |||
| 82f4281a02 | |||
| 12b6e30e3a | |||
| 567ca7e3f1 | |||
| 97c3ea7da8 | |||
| 4af2b032dd | |||
| c52d9a850d | |||
| 588e5bc17f | |||
| a2bd0edd82 | |||
| ca0f0638c9 | |||
| f13e6a3691 | |||
| dcb7b86df8 | |||
| 45c6e72c6d | |||
| a51456deb3 | |||
| 3ffe7e7671 | |||
| a7fe6c9af3 | |||
| 52c673261b | |||
| 60c0a53a1f | |||
| 66fae19034 | |||
| 37ea6bb92b | |||
| 762816adbc | |||
| d1b24b15d5 | |||
| 877b7ad0df | |||
| e98d931aaa | |||
| ba7d102a72 | |||
| ab1f00e919 |
@@ -65,7 +65,7 @@
|
||||
],
|
||||
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
|
||||
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
|
||||
"lodash/import-scope": ["warn", "method"],
|
||||
"lodash/import-scope": ["error", "method"],
|
||||
"import/no-named-as-default": "off",
|
||||
"import/no-named-as-default-member": "off",
|
||||
"import/newline-after-import": 2,
|
||||
|
||||
@@ -15,6 +15,8 @@ requestInfoDefaultTitles:
|
||||
|
||||
requestInfoLabelToAdd: more information needed
|
||||
|
||||
requestInfoUserstoExclude:
|
||||
- tommoor
|
||||
|
||||
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
|
||||
REDIS_URL: redis://127.0.0.1:6379
|
||||
URL: http://localhost:3000
|
||||
NODE_OPTIONS: --max-old-space-size=8000
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
|
||||
UTILS_SECRET: 123456
|
||||
SLACK_VERIFICATION_TOKEN: 123456
|
||||
@@ -63,6 +63,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
server: ${{ steps.filter.outputs.server }}
|
||||
app: ${{ steps.filter.outputs.app }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
@@ -81,7 +82,7 @@ jobs:
|
||||
- 'yarn.lock'
|
||||
|
||||
test:
|
||||
needs: build
|
||||
needs: [build, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -143,8 +144,8 @@ jobs:
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -161,3 +162,4 @@ jobs:
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
webpackStatsFile: ./build/app/webpack-stats.json
|
||||
|
||||
+179
-19
@@ -3,25 +3,32 @@ name: Docker
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
BASE_IMAGE_NAME: outlinewiki/outline-base
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
build-arm:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -29,24 +36,177 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
push: true
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
labels: ${{ steps.base_meta.outputs.labels }}
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Extract version
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push main image
|
||||
uses: docker/build-push-action@v5
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
|
||||
file: Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-linux-arm64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
build-amd:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.BASE_IMAGE_NAME }}
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
labels: ${{ steps.base_meta.outputs.labels }}
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-linux-amd64
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubicloud-standard-8
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
run-linters:
|
||||
if: startsWith(github.actor, 'codegen-sh')
|
||||
name: Run linters
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
# Give the default GITHUB_TOKEN write permission to commit and push the
|
||||
# added or changed files to the repository.
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'yarn'
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn lint --fix
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: 'Applied automatic fixes'
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM outlinewiki/outline-base AS base
|
||||
ARG BASE_IMAGE=outlinewiki/outline-base
|
||||
FROM ${BASE_IMAGE} AS base
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
@@ -30,7 +31,7 @@ RUN addgroup --gid 1001 nodejs && \
|
||||
adduser --uid 1001 --ingroup nodejs nodejs && \
|
||||
chown -R nodejs:nodejs $APP_PATH/build && \
|
||||
mkdir -p /var/lib/outline && \
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
chown -R nodejs:nodejs /var/lib/outline
|
||||
|
||||
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
|
||||
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
|
||||
|
||||
+4
-1
@@ -1,11 +1,14 @@
|
||||
ARG APP_PATH=/opt/outline
|
||||
FROM node:20-slim AS deps
|
||||
FROM node:20 AS deps
|
||||
|
||||
ARG APP_PATH
|
||||
WORKDIR $APP_PATH
|
||||
COPY ./package.json ./yarn.lock ./
|
||||
COPY ./patches ./patches
|
||||
|
||||
RUN apt-get update && apt-get install -y cmake
|
||||
ENV NODE_OPTIONS="--max-old-space-size=24000"
|
||||
|
||||
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
|
||||
yarn cache clean
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.82.0
|
||||
Licensed Work: Outline 0.83.0
|
||||
The Licensed Work is (c) 2025 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2029-02-15
|
||||
Change Date: 2029-04-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
PadlockIcon,
|
||||
GlobeIcon,
|
||||
LogoutIcon,
|
||||
CaseSensitiveIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -510,6 +511,25 @@ export const copyDocumentAsMarkdown = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentAsPlainText = createAction({
|
||||
name: ({ t }) => t("Copy as text"),
|
||||
section: ActiveDocumentSection,
|
||||
keywords: "clipboard",
|
||||
icon: <CaseSensitiveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ stores, activeDocumentId, t }) => {
|
||||
const document = activeDocumentId
|
||||
? stores.documents.get(activeDocumentId)
|
||||
: undefined;
|
||||
if (document) {
|
||||
copy(document.toPlainText());
|
||||
toast.success(t("Text copied to clipboard"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const copyDocumentShareLink = createAction({
|
||||
name: ({ t }) => t("Copy public link"),
|
||||
section: ActiveDocumentSection,
|
||||
@@ -555,7 +575,12 @@ export const copyDocument = createAction({
|
||||
section: ActiveDocumentSection,
|
||||
icon: <CopyIcon />,
|
||||
keywords: "clipboard",
|
||||
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
|
||||
children: [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
@@ -1205,6 +1230,7 @@ export const rootDocumentActions = [
|
||||
copyDocumentLink,
|
||||
copyDocumentShareLink,
|
||||
copyDocumentAsMarkdown,
|
||||
copyDocumentAsPlainText,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
publishDocument,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
import { logoutPath } from "~/utils/routeHelpers";
|
||||
import LoadingIndicator from "./LoadingIndicator";
|
||||
|
||||
type Props = {
|
||||
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
|
||||
}
|
||||
|
||||
void auth.logout(true);
|
||||
return <Redirect to={logoutPath()} />;
|
||||
return <Redirect to="/" />;
|
||||
};
|
||||
|
||||
export default observer(Authenticated);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import {
|
||||
Switch,
|
||||
Route,
|
||||
useLocation,
|
||||
matchPath,
|
||||
Redirect,
|
||||
} from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
@@ -10,6 +16,7 @@ import Sidebar from "~/components/Sidebar";
|
||||
import SidebarRight from "~/components/Sidebar/Right";
|
||||
import SettingsSidebar from "~/components/Sidebar/Settings";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
const can = usePolicy(ui.activeDocumentId);
|
||||
const canCollection = usePolicy(ui.activeCollectionId);
|
||||
const team = useCurrentTeam();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
|
||||
const goToSearch = (ev: KeyboardEvent) => {
|
||||
if (!ev.metaKey && !ev.ctrlKey) {
|
||||
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
return <ErrorSuspended />;
|
||||
}
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
const sidebar = (
|
||||
<Fade>
|
||||
<Switch>
|
||||
|
||||
@@ -7,17 +7,43 @@ import User from "~/models/User";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Avatar, { AvatarSize } from "./Avatar";
|
||||
|
||||
/**
|
||||
* Props for the AvatarWithPresence component
|
||||
*/
|
||||
type Props = {
|
||||
/** The user to display the avatar for */
|
||||
user: User;
|
||||
/** Whether the user is currently present in the document */
|
||||
isPresent: boolean;
|
||||
/** Whether the user is currently editing the document */
|
||||
isEditing: boolean;
|
||||
/** Whether the user is currently observing the document */
|
||||
isObserving: boolean;
|
||||
/** Whether this avatar represents the current user */
|
||||
isCurrentUser: boolean;
|
||||
/** Optional click handler for the avatar */
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
/** Size of the avatar, defaults to AvatarSize.Large */
|
||||
size?: AvatarSize;
|
||||
/** Optional inline styles to apply to the avatar wrapper */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* AvatarWithPresence component displays a user's avatar with visual indicators
|
||||
* for their current status (present, editing, observing).
|
||||
*
|
||||
* The component shows different visual states:
|
||||
* - Present users have full opacity
|
||||
* - Non-present users have reduced opacity
|
||||
* - Observing users have a colored border matching their user color
|
||||
* - Hovering shows a colored border
|
||||
*
|
||||
* A tooltip displays the user's name and current status.
|
||||
*
|
||||
* @param props - Component properties
|
||||
* @returns React component
|
||||
*/
|
||||
function AvatarWithPresence({
|
||||
onClick,
|
||||
user,
|
||||
@@ -64,16 +90,33 @@ function AvatarWithPresence({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered container for tooltip content
|
||||
*/
|
||||
const Centered = styled.div`
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Props for the AvatarPresence styled component
|
||||
*/
|
||||
type AvatarWrapperProps = {
|
||||
/** Whether the user is currently present */
|
||||
$isPresent: boolean;
|
||||
/** Whether the user is currently observing */
|
||||
$isObserving: boolean;
|
||||
/** The user's color for border highlighting */
|
||||
$color: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Styled component that wraps the Avatar and provides visual indicators
|
||||
* for the user's presence status.
|
||||
*
|
||||
* - Adjusts opacity based on presence
|
||||
* - Adds colored borders for observing users
|
||||
* - Handles hover effects
|
||||
*/
|
||||
const AvatarPresence = styled.div<AvatarWrapperProps>`
|
||||
opacity: ${(props) => (props.$isPresent ? 1 : 0.5)};
|
||||
transition: opacity 250ms ease-in-out;
|
||||
|
||||
@@ -2,6 +2,11 @@ import React from "react";
|
||||
import styled from "styled-components";
|
||||
import { fadeIn } from "~/styles/animations";
|
||||
|
||||
/**
|
||||
* Fade in animation for a component.
|
||||
*
|
||||
* @param timing - The duration of the fade in animation, default is 250ms.
|
||||
*/
|
||||
const Fade = styled.span<{ timing?: number | string }>`
|
||||
animation: ${fadeIn} ${(props) => props.timing || "250ms"} ease-in-out;
|
||||
`;
|
||||
@@ -17,7 +22,6 @@ type Props = {
|
||||
*/
|
||||
export const ConditionalFade = ({ animate, children }: Props) => {
|
||||
const [isAnimated] = React.useState(animate);
|
||||
|
||||
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { m } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
@@ -8,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import LoadingIndicator from "../LoadingIndicator";
|
||||
import { CARD_MARGIN } from "./Components";
|
||||
import HoverPreviewDocument from "./HoverPreviewDocument";
|
||||
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
|
||||
type Props = {
|
||||
/** The HTML element that is being hovered over, or null if none. */
|
||||
element: HTMLElement | null;
|
||||
/** Data to be previewed */
|
||||
data: Record<string, any> | null;
|
||||
/** Whether the preview data is being loaded */
|
||||
/** ID of the unfurl that will be shown in the hover preview. */
|
||||
unfurlId: string | null;
|
||||
/** Whether the preview data is being loaded. */
|
||||
dataLoading: boolean;
|
||||
/** A callback on close of the hover preview. */
|
||||
onClose: () => void;
|
||||
@@ -36,151 +38,155 @@ enum Direction {
|
||||
DOWN,
|
||||
}
|
||||
|
||||
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||
useHoverPosition({
|
||||
cardRef,
|
||||
element,
|
||||
isVisible,
|
||||
});
|
||||
const HoverPreviewDesktop = observer(
|
||||
({ element, unfurlId, dataLoading, onClose }: Props) => {
|
||||
const { unfurls } = useStores();
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const cardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
|
||||
useHoverPosition({
|
||||
cardRef,
|
||||
element,
|
||||
isVisible,
|
||||
});
|
||||
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
|
||||
|
||||
const closePreview = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
const closePreview = React.useCallback(() => {
|
||||
setVisible(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
// Open and close the preview when the element changes.
|
||||
React.useEffect(() => {
|
||||
if (element && data && !dataLoading) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
startCloseTimer();
|
||||
}
|
||||
}, [startCloseTimer, element, data, dataLoading]);
|
||||
|
||||
// Close the preview on Escape, scroll, or click outside.
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
// Ensure that the preview stays open while the user is hovering over the card.
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (isVisible) {
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
const stopCloseTimer = React.useCallback(() => {
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
timerClose.current = undefined;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return () => {
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
const startCloseTimer = React.useCallback(() => {
|
||||
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
|
||||
}, [closePreview]);
|
||||
|
||||
// Open and close the preview when the element changes.
|
||||
React.useEffect(() => {
|
||||
if (element && data && !dataLoading) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
startCloseTimer();
|
||||
}
|
||||
}, [startCloseTimer, element, data, dataLoading]);
|
||||
|
||||
// Close the preview on Escape, scroll, or click outside.
|
||||
useOnClickOutside(cardRef, closePreview);
|
||||
useKeyDown("Escape", closePreview);
|
||||
useEventListener("scroll", closePreview, window, { capture: true });
|
||||
|
||||
// Ensure that the preview stays open while the user is hovering over the card.
|
||||
React.useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
|
||||
if (isVisible) {
|
||||
if (card) {
|
||||
card.addEventListener("mouseenter", stopCloseTimer);
|
||||
card.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
}
|
||||
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||
return () => {
|
||||
if (card) {
|
||||
card.removeEventListener("mouseenter", stopCloseTimer);
|
||||
card.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (dataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
stopCloseTimer();
|
||||
};
|
||||
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
|
||||
|
||||
if (dataLoading) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
summary={data.summary}
|
||||
lastActivityByViewer={data.lastActivityByViewer}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Issue ? (
|
||||
<HoverPreviewIssue
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
labels={data.labels}
|
||||
state={data.state}
|
||||
createdAt={data.createdAt}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.PR ? (
|
||||
<HoverPreviewPullRequest
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Position top={cardTop} left={cardLeft} aria-hidden>
|
||||
{isVisible ? (
|
||||
<Animate
|
||||
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transitionEnd: { pointerEvents: "auto" },
|
||||
}}
|
||||
>
|
||||
{data.type === UnfurlResourceType.Mention ? (
|
||||
<HoverPreviewMention
|
||||
ref={cardRef}
|
||||
name={data.name}
|
||||
avatarUrl={data.avatarUrl}
|
||||
color={data.color}
|
||||
lastActive={data.lastActive}
|
||||
email={data.email}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Document ? (
|
||||
<HoverPreviewDocument
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
summary={data.summary}
|
||||
lastActivityByViewer={data.lastActivityByViewer}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.Issue ? (
|
||||
<HoverPreviewIssue
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
labels={data.labels}
|
||||
state={data.state}
|
||||
createdAt={data.createdAt}
|
||||
/>
|
||||
) : data.type === UnfurlResourceType.PR ? (
|
||||
<HoverPreviewPullRequest
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
id={data.id}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
author={data.author}
|
||||
createdAt={data.createdAt}
|
||||
state={data.state}
|
||||
/>
|
||||
) : (
|
||||
<HoverPreviewLink
|
||||
ref={cardRef}
|
||||
url={data.url}
|
||||
thumbnailUrl={data.thumbnailUrl}
|
||||
title={data.title}
|
||||
description={data.description}
|
||||
/>
|
||||
)}
|
||||
<Pointer
|
||||
top={pointerTop}
|
||||
left={pointerLeft}
|
||||
direction={pointerDir}
|
||||
/>
|
||||
</Animate>
|
||||
) : null}
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
|
||||
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
|
||||
const isMobile = useMobile();
|
||||
if (isMobile) {
|
||||
return null;
|
||||
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
|
||||
<HoverPreviewDesktop
|
||||
{...rest}
|
||||
element={element}
|
||||
data={data}
|
||||
unfurlId={unfurlId}
|
||||
dataLoading={dataLoading}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import Flex from "~/components/Flex";
|
||||
import { PullRequestIcon } from "../Icons/PullRequestIcon";
|
||||
import Text from "../Text";
|
||||
import Time from "../Time";
|
||||
import {
|
||||
|
||||
@@ -45,6 +45,10 @@ export const NativeInput = styled.input<{
|
||||
${ellipsis()}
|
||||
${undraggableOnDesktop()}
|
||||
|
||||
&[readOnly] {
|
||||
color: ${s("textSecondary")};
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
color: ${s("placeholder")};
|
||||
@@ -126,13 +130,14 @@ export interface Props
|
||||
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||
"prefix"
|
||||
> {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
rows?: number;
|
||||
/** Optional component that appears inside the input before the textarea and any icon */
|
||||
prefix?: React.ReactNode;
|
||||
/** Optional icon that appears inside the input before the textarea */
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import Flex from "~/components/Flex";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
label: React.ReactNode | string;
|
||||
};
|
||||
|
||||
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
<Label>{label}</Label>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
export const Label = styled(Flex)`
|
||||
font-weight: 500;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
color: ${s("text")};
|
||||
`;
|
||||
|
||||
export default observer(Labeled);
|
||||
@@ -114,6 +114,8 @@ const Modal: React.FC<Props> = ({
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
// maxHeight needed for proper overflow behavior in Safari
|
||||
style={{ maxHeight: "65vh" }}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
@@ -259,6 +261,7 @@ const Small = styled.div`
|
||||
width: 75vw;
|
||||
min-width: 350px;
|
||||
max-width: 450px;
|
||||
max-height: 65vh;
|
||||
z-index: ${depths.modal};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -54,6 +54,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const [hasPointerMoved, setPointerMoved] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
@@ -114,6 +116,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
|
||||
const handlePointerActivity = React.useCallback(() => {
|
||||
if (ui.sidebarIsClosed) {
|
||||
// clear the timeout when mouse exits
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
setHovering(document.hasFocus());
|
||||
setPointerMoved(true);
|
||||
}
|
||||
@@ -122,12 +128,20 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const handlePointerLeave = React.useCallback(
|
||||
(ev) => {
|
||||
if (hasPointerMoved) {
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
// clear any previous timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
// add a short delay when mouse exits the sidebar before closing
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovering(
|
||||
document.hasFocus() &&
|
||||
ev.pageX < width &&
|
||||
ev.pageY < window.innerHeight &&
|
||||
ev.pageY > 0
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
[width, hasPointerMoved]
|
||||
|
||||
@@ -148,7 +148,12 @@ function InnerDocumentLink(
|
||||
const color = document?.color || node.color;
|
||||
|
||||
// Draggable
|
||||
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
|
||||
const [{ isDragging }, drag] = useDragDocument(
|
||||
node,
|
||||
depth,
|
||||
document,
|
||||
isEditing
|
||||
);
|
||||
|
||||
// Drop to re-parent
|
||||
const parentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -270,6 +275,8 @@ function InnerDocumentLink(
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
// @ts-expect-error react-router type is wrong, string component is fine.
|
||||
component={isEditing ? "div" : undefined}
|
||||
expanded={hasChildren ? isExpanded : undefined}
|
||||
onDisclosureClick={handleDisclosureClick}
|
||||
onClickIntent={handlePrefetch}
|
||||
@@ -285,6 +292,7 @@ function InnerDocumentLink(
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={handleTitleChange}
|
||||
isEditing={isEditing}
|
||||
onEditing={setIsEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
to: LocationDescriptor;
|
||||
component?: React.ComponentType;
|
||||
onBeforeClick?: () => void;
|
||||
}
|
||||
|
||||
@@ -146,17 +147,22 @@ const NavLink = ({
|
||||
setPreActive(undefined);
|
||||
}, [currentLocation]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
},
|
||||
[navigateTo]
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={isActive ? "active" : "inactive"}
|
||||
ref={linkRef}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => {
|
||||
if (["Enter", " "].includes(event.key)) {
|
||||
navigateTo();
|
||||
event.currentTarget?.blur();
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-current={(isActive && ariaCurrent) || undefined}
|
||||
className={className}
|
||||
style={style}
|
||||
|
||||
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param node The NavigationNode model to drag.
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,7 +190,7 @@ export function useDragDocument(
|
||||
icon: icon ? <Icon value={icon} color={color} /> : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
} as DragObject),
|
||||
canDrag: () => !!document?.isActive,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
|
||||
+1
-71
@@ -1,73 +1,3 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
|
||||
type Props = {
|
||||
/** The type of text to render */
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
/** The size of the text */
|
||||
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
|
||||
/** The direction of the text (defaults to ltr) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the text should be selectable (defaults to false) */
|
||||
selectable?: boolean;
|
||||
/** The font weight of the text */
|
||||
weight?: "xbold" | "bold" | "normal";
|
||||
/** Whether the text should be italic */
|
||||
italic?: boolean;
|
||||
/** Whether the text should be truncated with an ellipsis */
|
||||
ellipsis?: boolean;
|
||||
/** Whether the text should be monospaced */
|
||||
monospace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this component for all interface text that should not be selectable
|
||||
* by the user, this is the majority of UI text explainers, notes, headings.
|
||||
*/
|
||||
const Text = styled.span<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
: props.type === "tertiary"
|
||||
? props.theme.textTertiary
|
||||
: props.type === "danger"
|
||||
? props.theme.brand.red
|
||||
: props.theme.text};
|
||||
font-size: ${(props) =>
|
||||
props.size === "xlarge"
|
||||
? "26px"
|
||||
: props.size === "large"
|
||||
? "18px"
|
||||
: props.size === "medium"
|
||||
? "16px"
|
||||
: props.size === "small"
|
||||
? "14px"
|
||||
: props.size === "xsmall"
|
||||
? "13px"
|
||||
: "inherit"};
|
||||
|
||||
${(props) =>
|
||||
props.weight &&
|
||||
css`
|
||||
font-weight: ${props.weight === "xbold"
|
||||
? 600
|
||||
: props.weight === "bold"
|
||||
? 500
|
||||
: props.weight === "normal"
|
||||
? 400
|
||||
: "inherit"};
|
||||
`}
|
||||
|
||||
font-style: ${(props) => (props.italic ? "italic" : "normal")};
|
||||
font-family: ${(props) =>
|
||||
props.monospace ? props.theme.fontFamilyMono : "inherit"};
|
||||
|
||||
white-space: normal;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
|
||||
${(props) => props.ellipsis && ellipsis()}
|
||||
`;
|
||||
import Text from "@shared/components/Text";
|
||||
|
||||
export default Text;
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { LinkIcon } from "outline-icons";
|
||||
import { observer } from "mobx-react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v4 } from "uuid";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import SuggestionsMenu, {
|
||||
Props as SuggestionsMenuProps,
|
||||
} from "./SuggestionsMenu";
|
||||
@@ -15,34 +23,65 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
|
||||
let mentionType: MentionType | undefined;
|
||||
const url = pastedText ? new URL(pastedText) : undefined;
|
||||
|
||||
if (url) {
|
||||
const integration = integrations.find((intg: Integration) =>
|
||||
isURLMentionable({ url, integration: intg })
|
||||
);
|
||||
|
||||
mentionType = integration
|
||||
? determineMentionType({ url, integration })
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const embed = React.useMemo(() => {
|
||||
for (const e of embeds) {
|
||||
const matches = e.matcher(props.pastedText);
|
||||
const matches = e.matcher(pastedText);
|
||||
if (matches) {
|
||||
return e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}, [embeds, props.pastedText]);
|
||||
}, [embeds, pastedText]);
|
||||
|
||||
const items = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
],
|
||||
[embed, t]
|
||||
() =>
|
||||
[
|
||||
{
|
||||
name: "noop",
|
||||
title: t("Keep as link"),
|
||||
icon: <LinkIcon />,
|
||||
},
|
||||
{
|
||||
name: "mention",
|
||||
title: t("Mention"),
|
||||
icon: <EmailIcon />,
|
||||
visible: !!mentionType,
|
||||
attrs: {
|
||||
id: v4(),
|
||||
type: mentionType,
|
||||
label: pastedText,
|
||||
href: pastedText,
|
||||
modelId: v4(),
|
||||
actorId: user?.id,
|
||||
},
|
||||
appendSpace: true,
|
||||
},
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
] satisfies MenuItem[],
|
||||
[t, embed, mentionType, pastedText, user]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
filterable={false}
|
||||
renderMenuItem={(item, _index, options) => (
|
||||
<SuggestionsMenuItem
|
||||
onClick={() => {
|
||||
props.onSelect?.(item);
|
||||
}}
|
||||
onClick={options.onClick}
|
||||
selected={options.selected}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasteMenu;
|
||||
});
|
||||
|
||||
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
|
||||
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
|
||||
if ((readOnly && !canComment) || isDragging) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
|
||||
const colIndex = getColumnIndex(state);
|
||||
const rowIndex = getRowIndex(state);
|
||||
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
|
||||
|
||||
@@ -645,12 +645,11 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
"section" in item ? item.section?.({ t }) : undefined;
|
||||
|
||||
const response = (
|
||||
<>
|
||||
<React.Fragment key={`${index}-${item.name}`}>
|
||||
{currentHeading !== previousHeading && (
|
||||
<Header key={currentHeading}>{currentHeading}</Header>
|
||||
)}
|
||||
<ListItem
|
||||
key={index}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
@@ -659,7 +658,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
onClick: () => handleClickItem(item),
|
||||
})}
|
||||
</ListItem>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
previousHeading = currentHeading;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isList } from "@shared/editor/queries/isList";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
|
||||
/**
|
||||
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
new Plugin({
|
||||
key: new PluginKey("clipboardTextSerializer"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
clipboardTextSerializer: (slice, view) => {
|
||||
const isMultiline = slice.content.childCount > 1;
|
||||
|
||||
// This is a cheap way to determine if the content is "complex",
|
||||
// aka it has multiple marks or formatting. In which case we'll use
|
||||
// markdown formatting
|
||||
const hasMultipleListItems = slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.some((node) => isList(node, view.state.schema));
|
||||
const hasMultipleBlockTypes =
|
||||
[
|
||||
...new Set(
|
||||
slice.content.content
|
||||
.filter((node) => node.content.content.length > 1)
|
||||
.map((node) => node.type.name)
|
||||
),
|
||||
].length > 1;
|
||||
const copyAsMarkdown =
|
||||
isMultiline ||
|
||||
slice.content.content.some(
|
||||
(node) => node.content.content.length > 1
|
||||
);
|
||||
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
|
||||
|
||||
return copyAsMarkdown
|
||||
? mdSerializer.serialize(slice.content, {
|
||||
|
||||
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import stores from "~/stores";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
interface HoverPreviewsOptions {
|
||||
/** Delay before the target is considered "hovered" and callback is triggered. */
|
||||
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
|
||||
export default class HoverPreviews extends Extension {
|
||||
state: {
|
||||
activeLinkElement: HTMLElement | null;
|
||||
data: Record<string, any> | null;
|
||||
unfurlId: string | null;
|
||||
dataLoading: boolean;
|
||||
} = observable({
|
||||
activeLinkElement: null,
|
||||
data: null,
|
||||
unfurlId: null,
|
||||
dataLoading: false,
|
||||
});
|
||||
|
||||
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
|
||||
);
|
||||
|
||||
if (url) {
|
||||
const transformedUrl = url.startsWith("/")
|
||||
? env.URL + url
|
||||
: url;
|
||||
|
||||
this.state.dataLoading = true;
|
||||
try {
|
||||
const data = await client.post("/urls.unfurl", {
|
||||
url: url.startsWith("/") ? env.URL + url : url,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const unfurl = await stores.unfurls.fetchUnfurl({
|
||||
url: transformedUrl,
|
||||
documentId,
|
||||
});
|
||||
|
||||
if (unfurl) {
|
||||
this.state.activeLinkElement = element;
|
||||
this.state.data = data;
|
||||
} catch (err) {
|
||||
this.state.unfurlId = transformedUrl;
|
||||
} else {
|
||||
this.state.activeLinkElement = null;
|
||||
} finally {
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
|
||||
this.state.dataLoading = false;
|
||||
}
|
||||
}),
|
||||
this.options.delay
|
||||
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
|
||||
widget = () => (
|
||||
<HoverPreview
|
||||
element={this.state.activeLinkElement}
|
||||
data={this.state.data}
|
||||
unfurlId={this.state.unfurlId}
|
||||
dataLoading={this.state.dataLoading}
|
||||
onClose={action(() => {
|
||||
this.state.activeLinkElement = null;
|
||||
this.state.unfurlId = null;
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
|
||||
import stores from "~/stores";
|
||||
import PasteMenu from "../components/PasteMenu";
|
||||
import { PasteMenu } from "../components/PasteMenu";
|
||||
|
||||
export default class PasteHandler extends Extension {
|
||||
state: {
|
||||
@@ -88,7 +88,7 @@ export default class PasteHandler extends Extension {
|
||||
|
||||
// If the users selection is currently in a code block then paste
|
||||
// as plain text, ignore all formatting and HTML content.
|
||||
if (isInCode(state)) {
|
||||
if (isInCode(state, { inclusive: true })) {
|
||||
event.preventDefault();
|
||||
view.dispatch(state.tr.insertText(text));
|
||||
return true;
|
||||
@@ -415,6 +415,21 @@ export default class PasteHandler extends Extension {
|
||||
});
|
||||
};
|
||||
|
||||
private insertMention = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
const result = this.findPlaceholder(state, this.state.pastedText);
|
||||
|
||||
// Remove just the placeholder here.
|
||||
// Mention node will be created by SuggestionsMenu.
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private removePlaceholder = () => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
@@ -450,6 +465,11 @@ export default class PasteHandler extends Extension {
|
||||
this.insertEmbed();
|
||||
break;
|
||||
}
|
||||
case "mention": {
|
||||
this.hidePasteMenu();
|
||||
this.insertMention();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/**
|
||||
* Hook that provides a dictionary of translated UI strings.
|
||||
*
|
||||
* @returns An object containing all translated UI strings used throughout the application
|
||||
*/
|
||||
export default function useDictionary() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
import * as React from "react";
|
||||
import useIsMounted from "@shared/hooks/useIsMounted";
|
||||
|
||||
/**
|
||||
* Hook to check if component is still mounted
|
||||
*
|
||||
* @returns {boolean} true if the component is mounted, false otherwise
|
||||
*/
|
||||
export default function useIsMounted() {
|
||||
const isMounted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return React.useCallback(() => isMounted.current, []);
|
||||
}
|
||||
export default useIsMounted;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import * as React from "react";
|
||||
import useWindowSize from "./useWindowSize";
|
||||
|
||||
/**
|
||||
* Hook to calculate the maximum height for an element based on its position and viewport size.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.elementRef A ref pointing to the element to calculate max height for
|
||||
* @param options.maxViewportPercentage The maximum height of the element as a percentage of the viewport
|
||||
* @param options.margin The margin to apply to the positioning
|
||||
* @returns Object containing the calculated maxHeight and a function to recalculate it
|
||||
*/
|
||||
const useMaxHeight = ({
|
||||
elementRef,
|
||||
maxViewportPercentage = 90,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook to check if a media query matches the current viewport.
|
||||
*
|
||||
* @param query The CSS media query to check against
|
||||
* @returns boolean indicating whether the media query matches
|
||||
*/
|
||||
export default function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(false);
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
|
||||
/**
|
||||
* Hook to detect if the current viewport is mobile-sized.
|
||||
*
|
||||
* @returns boolean indicating whether the current viewport is mobile-sized
|
||||
*/
|
||||
export default function useMobile(): boolean {
|
||||
return useMediaQuery(`(max-width: ${breakpoints.tablet - 1}px)`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Hook to access URL query parameters from the current location.
|
||||
*
|
||||
* @returns URLSearchParams object containing the current URL query parameters
|
||||
*/
|
||||
export default function useQuery() {
|
||||
const location = useLocation();
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
GlobeIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
SettingsIcon,
|
||||
ExportIcon,
|
||||
ImportIcon,
|
||||
@@ -40,7 +39,6 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
|
||||
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
|
||||
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
|
||||
const Security = lazy(() => import("~/scenes/Settings/Security"));
|
||||
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
|
||||
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
|
||||
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
|
||||
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
|
||||
@@ -177,14 +175,6 @@ const useSettingsConfig = () => {
|
||||
icon: ExportIcon,
|
||||
},
|
||||
// Integrations
|
||||
{
|
||||
name: t("Self Hosted"),
|
||||
path: integrationSettingsPath("self-hosted"),
|
||||
component: SelfHosted,
|
||||
enabled: can.update && !isCloudHosted,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
{
|
||||
name: "Zapier",
|
||||
path: integrationSettingsPath("zapier"),
|
||||
|
||||
@@ -2,6 +2,11 @@ import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import RootStore from "~/stores";
|
||||
|
||||
/**
|
||||
* Hook to access the MobX stores from the React context.
|
||||
*
|
||||
* @returns The root store containing all application stores
|
||||
*/
|
||||
export default function useStores() {
|
||||
return React.useContext(MobXProviderContext) as typeof RootStore;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Hook that executes a callback when the component unmounts.
|
||||
*
|
||||
* @param callback Function to be called on component unmount
|
||||
*/
|
||||
const useUnmount = (callback: (...args: Array<any>) => any) => {
|
||||
const ref = React.useRef(callback);
|
||||
ref.current = callback;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Hook to get the current viewport height, accounting for mobile virtual keyboards.
|
||||
* Uses the VisualViewport API when available, falling back to window.innerHeight.
|
||||
*
|
||||
* @returns The current viewport height in pixels
|
||||
*/
|
||||
export default function useViewportHeight(): number | void {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport#browser_compatibility
|
||||
// Note: No support in Firefox at time of writing, however this mainly exists
|
||||
|
||||
@@ -13,6 +13,13 @@ const defaultOptions = {
|
||||
throttle: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to track the window's scroll position.
|
||||
*
|
||||
* @param options Configuration options
|
||||
* @param options.throttle Time in milliseconds to throttle the scroll event
|
||||
* @returns Object containing the current scroll position (x, y coordinates)
|
||||
*/
|
||||
export default function useWindowScrollPosition(options: {
|
||||
throttle: number;
|
||||
}): {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
NavigationNodeType,
|
||||
NotificationEventType,
|
||||
} from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
@@ -663,6 +664,24 @@ export default class Document extends ArchivableModel implements Searchable {
|
||||
return markdown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the plain text representation of the document derived from the ProseMirror data.
|
||||
*
|
||||
* @returns The plain text representation of the document as a string.
|
||||
*/
|
||||
toPlainText = () => {
|
||||
const extensionManager = new ExtensionManager(withComments(richExtensions));
|
||||
const schema = new Schema({
|
||||
nodes: extensionManager.nodes,
|
||||
marks: extensionManager.marks,
|
||||
});
|
||||
const text = ProsemirrorHelper.toPlainText(
|
||||
Node.fromJSON(schema, this.data),
|
||||
schema
|
||||
);
|
||||
return text;
|
||||
};
|
||||
|
||||
download = (contentType: ExportContentType) =>
|
||||
client.post(
|
||||
`/documents.export`,
|
||||
|
||||
@@ -15,6 +15,9 @@ class Import extends Model {
|
||||
/** The name of the import. */
|
||||
name: string;
|
||||
|
||||
/** Descriptive error message when the import errors out. */
|
||||
error: string | null;
|
||||
|
||||
/** The current state of the import. */
|
||||
@Field
|
||||
@observable
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observable } from "mobx";
|
||||
import type {
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
type IntegrationSettings,
|
||||
type IntegrationType,
|
||||
} from "@shared/types";
|
||||
import User from "~/models/User";
|
||||
import Model from "~/models/base/Model";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { observable } from "mobx";
|
||||
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
|
||||
import Model from "./base/Model";
|
||||
|
||||
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
|
||||
static modelName = "Unfurl";
|
||||
|
||||
@observable
|
||||
type: UnfurlType;
|
||||
|
||||
@observable
|
||||
data: UnfurlResponse[UnfurlType];
|
||||
|
||||
@observable
|
||||
fetchedAt: string;
|
||||
}
|
||||
|
||||
export default Unfurl;
|
||||
@@ -16,6 +16,7 @@ import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Facepile from "~/components/Facepile";
|
||||
import Fade from "~/components/Fade";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
@@ -63,7 +64,7 @@ function CommentThread({
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const sidebarContext = useLocationSidebarContext();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
|
||||
|
||||
const can = usePolicy(document);
|
||||
|
||||
@@ -156,9 +157,9 @@ function CommentThread({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
setAutoFocusOff();
|
||||
}
|
||||
}, [focused, autoFocus]);
|
||||
}, [focused, autoFocus, setAutoFocusOff]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused) {
|
||||
@@ -273,7 +274,7 @@ function CommentThread({
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && !draft && canReply && (
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
<Reply onClick={setAutoFocusOn}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
);
|
||||
|
||||
+1
-8
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { HomeIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch, Route, Redirect } from "react-router-dom";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Action } from "~/components/Actions";
|
||||
@@ -18,7 +18,6 @@ import Tab from "~/components/Tab";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -29,16 +28,10 @@ function Home() {
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const [spendPostLoginPath] = usePostLoginPath();
|
||||
const userId = user?.id;
|
||||
const { pins, count } = usePinnedDocuments("home");
|
||||
const can = usePolicy(team);
|
||||
|
||||
const postLoginPath = spendPostLoginPath();
|
||||
if (postLoginPath) {
|
||||
return <Redirect to={postLoginPath} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Scene
|
||||
icon={<HomeIcon />}
|
||||
|
||||
@@ -209,7 +209,9 @@ function Invite({ onSubmit }: Props) {
|
||||
placeholder={`name@${predictedDomain}`}
|
||||
value={invite.email}
|
||||
required={index === 0}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
data-1p-ignore
|
||||
flex
|
||||
/>
|
||||
<StyledInput
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import find from "lodash/find";
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
type FormData = {
|
||||
drawIoUrl: string;
|
||||
gristUrl: string;
|
||||
};
|
||||
|
||||
function SelfHosted() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const integrationDiagrams = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const integrationGrist = find(integrations.orderedData, {
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
}) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
const {
|
||||
register,
|
||||
reset,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
void integrations.fetchPage({
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
React.useEffect(() => {
|
||||
reset({
|
||||
drawIoUrl: integrationDiagrams?.settings.url,
|
||||
gristUrl: integrationGrist?.settings.url,
|
||||
});
|
||||
}, [integrationDiagrams, integrationGrist, reset]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
if (data.drawIoUrl) {
|
||||
await integrations.save({
|
||||
id: integrationDiagrams?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Diagrams,
|
||||
settings: {
|
||||
url: data.drawIoUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationDiagrams?.delete();
|
||||
}
|
||||
|
||||
if (data.gristUrl) {
|
||||
await integrations.save({
|
||||
id: integrationGrist?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: IntegrationService.Grist,
|
||||
settings: {
|
||||
url: data.gristUrl,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await integrationGrist?.delete();
|
||||
}
|
||||
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[integrations, integrationDiagrams, integrationGrist, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
|
||||
<Heading>{t("Self Hosted")}</Heading>
|
||||
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<SettingRow
|
||||
label={t("Draw.io deployment")}
|
||||
name="drawIoUrl"
|
||||
description={t(
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://app.diagrams.net/"
|
||||
pattern="https?://.*"
|
||||
{...register("drawIoUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("Grist deployment")}
|
||||
name="gristUrl"
|
||||
description={t("Add your self-hosted grist installation URL here.")}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://docs.getgrist.com/"
|
||||
pattern="https?://.*"
|
||||
{...register("gristUrl")}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(SelfHosted);
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import {
|
||||
FileOperationFormat,
|
||||
FileOperationState,
|
||||
@@ -13,7 +14,6 @@ import FileOperation from "~/models/FileOperation";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import AvatarEditor from "react-avatar-editor";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Button from "~/components/Button";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import Flex from "~/components/Flex";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Modal from "~/components/Modal";
|
||||
import withStores from "~/components/withStores";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { uploadFile, dataUrlToBlob } from "~/utils/files";
|
||||
|
||||
@@ -24,132 +23,167 @@ export type Props = {
|
||||
borderRadius?: number;
|
||||
};
|
||||
|
||||
@observer
|
||||
class ImageUpload extends React.Component<RootStore & Props> {
|
||||
@observable
|
||||
isUploading = false;
|
||||
const ImageUpload: React.FC<Props> = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
submitText,
|
||||
borderRadius,
|
||||
children,
|
||||
}) => {
|
||||
const { dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@observable
|
||||
isCropping = false;
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
|
||||
@observable
|
||||
zoom = 1;
|
||||
const uploadImage = React.useCallback(
|
||||
async (blob: Blob, file: File) => {
|
||||
try {
|
||||
const compressed = await compressImage(blob, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
onError(err.message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
dialogs.closeAllModals();
|
||||
}
|
||||
},
|
||||
[dialogs, onSuccess, onError]
|
||||
);
|
||||
|
||||
@observable
|
||||
file: File;
|
||||
const handleUpload = React.useCallback(
|
||||
(blob: Blob, file: File) => {
|
||||
setIsUploading(true);
|
||||
// allow the UI to update before converting the canvas to a Blob
|
||||
// for large images this can cause the page rendering to hang.
|
||||
setTimeout(() => uploadImage(blob, file), 0);
|
||||
},
|
||||
[uploadImage]
|
||||
);
|
||||
|
||||
avatarEditorRef = React.createRef<AvatarEditor>();
|
||||
const handleClose = React.useCallback(() => {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
}, []);
|
||||
|
||||
static defaultProps = {
|
||||
submitText: "Crop Image",
|
||||
borderRadius: 150,
|
||||
};
|
||||
|
||||
onDropAccepted = async (files: File[]) => {
|
||||
this.isCropping = true;
|
||||
this.file = files[0];
|
||||
};
|
||||
|
||||
handleCrop = () => {
|
||||
this.isUploading = true;
|
||||
// allow the UI to update before converting the canvas to a Blob
|
||||
// for large images this can cause the page rendering to hang.
|
||||
setTimeout(this.uploadImage, 0);
|
||||
};
|
||||
|
||||
uploadImage = async () => {
|
||||
const canvas = this.avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const imageBlob = dataUrlToBlob(canvas.toDataURL());
|
||||
|
||||
try {
|
||||
const compressed = await compressImage(imageBlob, {
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: this.file.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void this.props.onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
this.props.onError(err.message);
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
};
|
||||
|
||||
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
this.zoom = parseFloat(target.value);
|
||||
}
|
||||
};
|
||||
|
||||
renderCropping() {
|
||||
const { ui, submitText } = this.props;
|
||||
return (
|
||||
<Modal isOpen onRequestClose={this.handleClose} title="">
|
||||
<Flex auto column align="center" justify="center">
|
||||
{this.isUploading && <LoadingIndicator />}
|
||||
<AvatarEditorContainer>
|
||||
<AvatarEditor
|
||||
ref={this.avatarEditorRef}
|
||||
image={this.file}
|
||||
width={250}
|
||||
height={250}
|
||||
border={25}
|
||||
borderRadius={this.props.borderRadius}
|
||||
color={
|
||||
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
|
||||
} // RGBA
|
||||
scale={this.zoom}
|
||||
rotate={0}
|
||||
/>
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={this.handleZoom}
|
||||
const onDropAccepted = React.useCallback(
|
||||
async (files: File[]) => {
|
||||
setIsCropping(true);
|
||||
dialogs.openModal({
|
||||
title: "",
|
||||
content: (
|
||||
<AvatarEditorDialog
|
||||
file={files[0]}
|
||||
onUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
borderRadius={borderRadius ?? 150}
|
||||
submitText={submitText || t("Crop image")}
|
||||
/>
|
||||
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
|
||||
{this.isUploading ? "Uploading…" : submitText}
|
||||
</CropButton>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
),
|
||||
onClose: handleClose,
|
||||
});
|
||||
},
|
||||
[
|
||||
t,
|
||||
dialogs,
|
||||
handleUpload,
|
||||
handleClose,
|
||||
isUploading,
|
||||
borderRadius,
|
||||
submitText,
|
||||
]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: AttachmentValidation.avatarContentTypes.join(", "),
|
||||
onDropAccepted,
|
||||
});
|
||||
|
||||
if (isCropping) {
|
||||
return null; // onDropAccepted would have opened a modal for cropping the image.
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.isCropping) {
|
||||
return this.renderCropping();
|
||||
}
|
||||
return (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AvatarEditorDialogProps = {
|
||||
file: File;
|
||||
onUpload: (blob: Blob, file: File) => void;
|
||||
isUploading: boolean;
|
||||
borderRadius: number;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
|
||||
({ file, onUpload, isUploading, borderRadius, submitText }) => {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const avatarEditorRef = useRef<AvatarEditor>(null);
|
||||
|
||||
const handleUpload = React.useCallback(() => {
|
||||
const canvas = avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const blob = dataUrlToBlob(canvas.toDataURL());
|
||||
onUpload(blob, file);
|
||||
}, [file, onUpload]);
|
||||
|
||||
const handleZoom = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
setZoom(parseFloat(target.value));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
<Flex auto column align="center" justify="center">
|
||||
{isUploading && <LoadingIndicator />}
|
||||
<AvatarEditorContainer>
|
||||
<AvatarEditor
|
||||
ref={avatarEditorRef}
|
||||
image={file}
|
||||
width={250}
|
||||
height={250}
|
||||
border={25}
|
||||
borderRadius={borderRadius}
|
||||
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
|
||||
scale={zoom}
|
||||
rotate={0}
|
||||
/>
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={handleZoom}
|
||||
/>
|
||||
<br />
|
||||
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? `${t(`Uploading`)}…` : submitText}
|
||||
</ButtonLarge>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const AvatarEditorContainer = styled(Flex)`
|
||||
margin-bottom: 30px;
|
||||
@@ -180,8 +214,4 @@ const RangeInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
const CropButton = styled(Button)`
|
||||
width: 300px;
|
||||
`;
|
||||
|
||||
export default withStores(ImageUpload);
|
||||
export default observer(ImageUpload);
|
||||
|
||||
@@ -5,16 +5,17 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import Spinner from "@shared/components/Spinner";
|
||||
import { ImportState } from "@shared/types";
|
||||
import Import from "~/models/Import";
|
||||
import { Action } from "~/components/Actions";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Spinner from "~/components/Spinner";
|
||||
import Time from "~/components/Time";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { ImportMenu } from "~/menus/ImportMenu";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
|
||||
type Props = {
|
||||
/** Import that's displayed as list item. */
|
||||
@@ -29,6 +30,10 @@ export const ImportListItem = observer(({ importModel }: Props) => {
|
||||
const showProgress =
|
||||
importModel.state !== ImportState.Canceled &&
|
||||
importModel.state !== ImportState.Errored;
|
||||
const showErrorInfo =
|
||||
!isCloudHosted &&
|
||||
importModel.state === ImportState.Errored &&
|
||||
!!importModel.error;
|
||||
|
||||
const stateMap = React.useMemo(
|
||||
() => ({
|
||||
@@ -114,6 +119,12 @@ export const ImportListItem = observer(({ importModel }: Props) => {
|
||||
subtitle={
|
||||
<>
|
||||
{stateMap[importModel.state]} •
|
||||
{showErrorInfo && (
|
||||
<>
|
||||
{importModel.error}
|
||||
{`. ${t("Check server logs for more details.")}`} •
|
||||
</>
|
||||
)}
|
||||
{t(`{{userName}} requested`, {
|
||||
userName:
|
||||
user.id === importModel.createdBy.id
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { FileOperationFormat } from "@shared/types";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DropToImport from "./DropToImport";
|
||||
import HelpDisclosure from "./HelpDisclosure";
|
||||
|
||||
function ImportNotionDialog() {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HelpDisclosure title={<Trans>Where do I find the file?</Trans>}>
|
||||
<Trans
|
||||
defaults="In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability."
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</HelpDisclosure>
|
||||
<DropToImport
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
format={FileOperationFormat.Notion}
|
||||
>
|
||||
<>
|
||||
{t(
|
||||
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
|
||||
)}
|
||||
</>
|
||||
</DropToImport>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImportNotionDialog;
|
||||
@@ -306,7 +306,7 @@ export default class AuthStore extends Store<Team> {
|
||||
// if this logout was forced from an authenticated route then
|
||||
// save the current path so we can go back there once signed in
|
||||
if (savePath) {
|
||||
setPostLoginPath(window.location.pathname);
|
||||
setPostLoginPath(window.location.pathname + window.location.search);
|
||||
}
|
||||
|
||||
if (tryRevokingToken) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import SharesStore from "./SharesStore";
|
||||
import StarsStore from "./StarsStore";
|
||||
import SubscriptionsStore from "./SubscriptionsStore";
|
||||
import UiStore from "./UiStore";
|
||||
import UnfurlsStore from "./UnfurlsStore";
|
||||
import UserMembershipsStore from "./UserMembershipsStore";
|
||||
import UsersStore from "./UsersStore";
|
||||
import ViewsStore from "./ViewsStore";
|
||||
@@ -55,6 +56,7 @@ export default class RootStore {
|
||||
searches: SearchesStore;
|
||||
shares: SharesStore;
|
||||
ui: UiStore;
|
||||
unfurls: UnfurlsStore;
|
||||
stars: StarsStore;
|
||||
subscriptions: SubscriptionsStore;
|
||||
users: UsersStore;
|
||||
@@ -85,6 +87,7 @@ export default class RootStore {
|
||||
this.registerStore(SharesStore);
|
||||
this.registerStore(StarsStore);
|
||||
this.registerStore(SubscriptionsStore);
|
||||
this.registerStore(UnfurlsStore);
|
||||
this.registerStore(UsersStore);
|
||||
this.registerStore(ViewsStore);
|
||||
this.registerStore(FileOperationsStore);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { subMinutes } from "date-fns";
|
||||
import { action } from "mobx";
|
||||
import { UnfurlResourceType } from "@shared/types";
|
||||
import Unfurl from "~/models/Unfurl";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Logger from "~/utils/Logger";
|
||||
import RootStore from "./RootStore";
|
||||
import Store from "./base/Store";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class UnfurlsStore extends Store<Unfurl<any>> {
|
||||
actions = []; // no default actions allowed for unfurls.
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Unfurl);
|
||||
}
|
||||
|
||||
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
|
||||
url,
|
||||
documentId,
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
}): Promise<Unfurl<UnfurlType> | undefined> => {
|
||||
const unfurl = this.get(url);
|
||||
|
||||
if (unfurl) {
|
||||
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
|
||||
return unfurl;
|
||||
}
|
||||
|
||||
return this.unfurl<UnfurlType>({ url, documentId });
|
||||
};
|
||||
|
||||
private refetch = <UnfurlType extends UnfurlResourceType>({
|
||||
unfurl,
|
||||
documentId,
|
||||
}: {
|
||||
unfurl: Unfurl<UnfurlType>;
|
||||
documentId?: string;
|
||||
}) => {
|
||||
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
||||
|
||||
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
|
||||
void this.unfurl({ url: unfurl.id, documentId });
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
private unfurl = async <UnfurlType extends UnfurlResourceType>({
|
||||
url,
|
||||
documentId,
|
||||
}: {
|
||||
url: string;
|
||||
documentId?: string;
|
||||
}): Promise<Unfurl<UnfurlType> | undefined> => {
|
||||
try {
|
||||
this.isFetching = true;
|
||||
|
||||
const data = await client.post("/urls.unfurl", {
|
||||
url,
|
||||
documentId,
|
||||
});
|
||||
|
||||
// unfurls can succeed with no data.
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.add({
|
||||
id: url,
|
||||
type: data.type,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
data,
|
||||
} as Unfurl<UnfurlType>);
|
||||
} catch (err) {
|
||||
Logger.error(`Failed to unfurl url ${url}`, err);
|
||||
return;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default UnfurlsStore;
|
||||
@@ -1,5 +1,6 @@
|
||||
import commandScore from "command-score";
|
||||
import invariant from "invariant";
|
||||
// eslint-disable-next-line lodash/import-scope
|
||||
import type { ObjectIterateeCustom } from "lodash";
|
||||
import deburr from "lodash/deburr";
|
||||
import filter from "lodash/filter";
|
||||
@@ -98,7 +99,14 @@ export default abstract class Store<T extends Model> {
|
||||
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
|
||||
|
||||
if (!normalized) {
|
||||
return this.orderedData.slice(0, options?.maxResults);
|
||||
return this.orderedData
|
||||
.filter((item) => {
|
||||
if ("deletedAt" in item && item.deletedAt) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, options?.maxResults);
|
||||
}
|
||||
|
||||
return this.orderedData
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationSettings,
|
||||
IntegrationType,
|
||||
MentionType,
|
||||
} from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
|
||||
export const isURLMentionable = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): boolean => {
|
||||
const { hostname, pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const settings =
|
||||
integration.settings as IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
return (
|
||||
hostname === "github.com" &&
|
||||
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const determineMentionType = ({
|
||||
url,
|
||||
integration,
|
||||
}: {
|
||||
url: URL;
|
||||
integration: Integration;
|
||||
}): MentionType | undefined => {
|
||||
const { pathname } = url;
|
||||
const pathParts = pathname.split("/");
|
||||
|
||||
switch (integration.service) {
|
||||
case IntegrationService.GitHub: {
|
||||
const type = pathParts[3];
|
||||
return type === "pull"
|
||||
? MentionType.PullRequest
|
||||
: type === "issues"
|
||||
? MentionType.Issue
|
||||
: undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
+5
-3
@@ -49,8 +49,10 @@ export function redirectTo(url: string) {
|
||||
/**
|
||||
* Check if the path is a valid path for redirect after login.
|
||||
*
|
||||
* @param path
|
||||
* @param input A path potentially including query string
|
||||
* @returns boolean indicating if the path is a valid redirect
|
||||
*/
|
||||
export const isAllowedLoginRedirect = (path: string) =>
|
||||
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
|
||||
export const isAllowedLoginRedirect = (input: string) => {
|
||||
const path = input.split("?")[0];
|
||||
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
|
||||
};
|
||||
|
||||
+21
-20
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.774.0",
|
||||
"@aws-sdk/lib-storage": "3.774.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.774.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.774.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.774.0",
|
||||
"@aws-sdk/client-s3": "3.787.0",
|
||||
"@aws-sdk/lib-storage": "3.787.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.787.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.787.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.787.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
@@ -89,7 +89,7 @@
|
||||
"@sentry/node": "^7.120.3",
|
||||
"@sentry/react": "^7.120.3",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tanstack/react-virtual": "^3.11.3",
|
||||
"@tanstack/react-virtual": "^3.13.6",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.2",
|
||||
"@types/mailparser": "^3.4.5",
|
||||
@@ -141,7 +141,7 @@
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.21",
|
||||
"kbar": "0.1.0-beta.41",
|
||||
"koa": "^2.15.4",
|
||||
"koa": "^2.16.1",
|
||||
"koa-body": "^6.0.1",
|
||||
"koa-compress": "^5.1.1",
|
||||
"koa-helmet": "^6.1.0",
|
||||
@@ -153,7 +153,7 @@
|
||||
"koa-useragent": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mailparser": "^3.7.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"mammoth": "^1.9.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
@@ -183,11 +183,11 @@
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-inputrules": "^1.5.0",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-markdown": "^1.13.1",
|
||||
"prosemirror-model": "^1.24.0",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"prosemirror-markdown": "^1.13.2",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "1.10.0",
|
||||
@@ -210,7 +210,7 @@
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.2.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.21",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.11",
|
||||
"reakit": "^1.3.11",
|
||||
@@ -232,7 +232,7 @@
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"socket.io-redis": "^6.1.1",
|
||||
"sonner": "^1.7.1",
|
||||
"sonner": "^1.7.4",
|
||||
"stoppable": "^1.1.0",
|
||||
"string-replace-to-array": "^2.1.1",
|
||||
"styled-components": "^5.3.11",
|
||||
@@ -248,7 +248,7 @@
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^5.4.15",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -263,7 +263,7 @@
|
||||
"@babel/cli": "^7.27.0",
|
||||
"@babel/preset-typescript": "^7.27.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.14",
|
||||
"@relative-ci/agent": "^4.3.0",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
@@ -296,7 +296,7 @@
|
||||
"@types/markdown-it-emoji": "^2.0.4",
|
||||
"@types/mime-types": "^2.1.4",
|
||||
"@types/natural-sort": "^0.0.24",
|
||||
"@types/node": "20.17.27",
|
||||
"@types/node": "20.17.30",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/passport-oauth2": "^1.4.17",
|
||||
@@ -362,7 +362,7 @@
|
||||
"rimraf": "^2.5.4",
|
||||
"rollup-plugin-webpack-stats": "^2.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-static-copy": "^0.17.0",
|
||||
"yarn-deduplicate": "^6.0.2"
|
||||
},
|
||||
@@ -377,5 +377,6 @@
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.82.0"
|
||||
}
|
||||
"version": "0.83.0",
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
|
||||
/** Default user and team names metadata */
|
||||
let userName = profile.username;
|
||||
let teamName = "Wiki";
|
||||
let teamName;
|
||||
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
|
||||
let teamAvatarUrl: string | undefined = undefined;
|
||||
let subdomain = slugifyDomain(domain);
|
||||
|
||||
@@ -206,12 +206,12 @@ export class GitHub {
|
||||
);
|
||||
const { data } = await client.requestResource(resource);
|
||||
if (!data) {
|
||||
return;
|
||||
return { error: "Resource not found" };
|
||||
}
|
||||
return { ...data, type: resource.type };
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to fetch resource from GitHub", err);
|
||||
return;
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSONObject, UnfurlResourceType } from "@shared/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { UnfurlSignature } from "@server/types";
|
||||
import { UnfurlError, UnfurlSignature } from "@server/types";
|
||||
import fetch from "@server/utils/fetch";
|
||||
import env from "./env";
|
||||
|
||||
@@ -10,7 +10,7 @@ class Iframely {
|
||||
public static async requestResource(
|
||||
url: string,
|
||||
type = "oembed"
|
||||
): Promise<JSONObject | undefined> {
|
||||
): Promise<JSONObject | UnfurlError> {
|
||||
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
|
||||
|
||||
// Cloud Iframely requires /api path, while self-hosted does not.
|
||||
@@ -25,7 +25,7 @@ class Iframely {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
|
||||
return;
|
||||
return { error: err.message || "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ class Iframely {
|
||||
*/
|
||||
public static unfurl: UnfurlSignature = async (url: string) => {
|
||||
const data = await Iframely.requestResource(url);
|
||||
return { ...data, type: UnfurlResourceType.OEmbed };
|
||||
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
|
||||
? ({ error: data.error } as UnfurlError)
|
||||
: { ...data, type: UnfurlResourceType.OEmbed };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIResponseError,
|
||||
Client,
|
||||
isFullPage,
|
||||
isFullPageOrDatabase,
|
||||
@@ -11,9 +13,11 @@ import {
|
||||
RichTextItemResponse,
|
||||
} from "@notionhq/client/build/src/api-endpoints";
|
||||
import { RateLimit } from "async-sema";
|
||||
import emojiRegex from "emoji-regex";
|
||||
import compact from "lodash/compact";
|
||||
import { z } from "zod";
|
||||
import { Second } from "@shared/utils/time";
|
||||
import { isUrl } from "@shared/utils/urls";
|
||||
import { NotionUtils } from "../shared/NotionUtils";
|
||||
import { Block, Page, PageType } from "../shared/types";
|
||||
import env from "./env";
|
||||
@@ -35,7 +39,16 @@ const AccessTokenResponseSchema = z.object({
|
||||
bot_id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
workspace_name: z.string().nullish(),
|
||||
workspace_icon: z.string().url().nullish(),
|
||||
workspace_icon: z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((val) => {
|
||||
const emojiRegexp = emojiRegex();
|
||||
if (val && (isUrl(val) || emojiRegexp.test(val))) {
|
||||
return val;
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
|
||||
export class NotionClient {
|
||||
@@ -247,19 +260,30 @@ export class NotionClient {
|
||||
|
||||
private async fetchUsername(userId: string) {
|
||||
await this.limiter();
|
||||
const user = await this.client.users.retrieve({ user_id: userId });
|
||||
try {
|
||||
const user = await this.client.users.retrieve({ user_id: userId });
|
||||
|
||||
if (user.type === "person" || !user.bot.owner) {
|
||||
if (user.type === "person" || !user.bot.owner) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a user, get the user's name.
|
||||
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
|
||||
return user.bot.owner.user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a workspace, fallback to bot's name.
|
||||
return user.name;
|
||||
} catch (error) {
|
||||
// Handle the case where a user can't be found
|
||||
if (
|
||||
error instanceof APIResponseError &&
|
||||
error.code === APIErrorCode.ObjectNotFound
|
||||
) {
|
||||
return "Unknown";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// bot belongs to a user, get the user's name.
|
||||
if (user.bot.owner.type === "user" && isFullUser(user.bot.owner.user)) {
|
||||
return user.bot.owner.user.name;
|
||||
}
|
||||
|
||||
// bot belongs to a workspace, fallback to bot's name.
|
||||
return user.name;
|
||||
}
|
||||
|
||||
private parseTitle(item: PageObjectResponse | DatabaseObjectResponse) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { APIResponseError, APIErrorCode } from "@notionhq/client";
|
||||
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
|
||||
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration } from "@server/models";
|
||||
import ImportTask from "@server/models/ImportTask";
|
||||
import APIImportTask, {
|
||||
@@ -39,7 +41,10 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
importTask.input.map(async (item) => this.processPage({ item, client }))
|
||||
);
|
||||
|
||||
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
|
||||
// Filter out any null results (from pages/databases that couldn't be accessed)
|
||||
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
|
||||
|
||||
const taskOutput: ImportTaskOutput = validParsedPages.map((parsedPage) => ({
|
||||
externalId: parsedPage.externalId,
|
||||
title: parsedPage.title,
|
||||
emoji: parsedPage.emoji,
|
||||
@@ -50,7 +55,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
}));
|
||||
|
||||
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
|
||||
parsedPages.flatMap((parsedPage) =>
|
||||
validParsedPages.flatMap((parsedPage) =>
|
||||
parsedPage.children.map((childPage) => ({
|
||||
type: childPage.type,
|
||||
externalId: childPage.externalId,
|
||||
@@ -88,36 +93,55 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
|
||||
}: {
|
||||
item: ImportTaskInput<IntegrationService.Notion>[number];
|
||||
client: NotionClient;
|
||||
}): Promise<ParsePageOutput> {
|
||||
}): Promise<ParsePageOutput | null> {
|
||||
const collectionExternalId = item.collectionExternalId ?? item.externalId;
|
||||
|
||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||
if (item.type === PageType.Database) {
|
||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
||||
item.externalId
|
||||
);
|
||||
try {
|
||||
// Convert Notion database to an empty page with "pages in database" as its children.
|
||||
if (item.type === PageType.Database) {
|
||||
const { pages, ...databaseInfo } = await client.fetchDatabase(
|
||||
item.externalId
|
||||
);
|
||||
|
||||
return {
|
||||
...databaseInfo,
|
||||
externalId: item.externalId,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
collectionExternalId,
|
||||
children: pages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
|
||||
|
||||
return {
|
||||
...databaseInfo,
|
||||
...pageInfo,
|
||||
externalId: item.externalId,
|
||||
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
|
||||
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||
collectionExternalId,
|
||||
children: pages.map((page) => ({
|
||||
type: page.type,
|
||||
externalId: page.id,
|
||||
})),
|
||||
children: this.parseChildPages(blocks),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof APIResponseError) {
|
||||
// Skip this page/database if it's not found or not accessible
|
||||
if (
|
||||
error.code === APIErrorCode.ObjectNotFound ||
|
||||
error.code === APIErrorCode.Unauthorized
|
||||
) {
|
||||
Logger.warn(
|
||||
`Skipping Notion ${
|
||||
item.type === PageType.Database ? "database" : "page"
|
||||
} ${item.externalId} - Error code: ${error.code} - ${error.message}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Re-throw other errors to be handled by the parent try/catch
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
|
||||
|
||||
return {
|
||||
...pageInfo,
|
||||
externalId: item.externalId,
|
||||
content: NotionConverter.page({ children: blocks } as NotionPage),
|
||||
collectionExternalId,
|
||||
children: this.parseChildPages(blocks),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,109 @@
|
||||
import { Server } from "@hocuspocus/server";
|
||||
import WebSocket from "ws";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import { sleep } from "@server/utils/timers";
|
||||
import { ConnectionLimitExtension } from "./ConnectionLimitExtension";
|
||||
import { EditorVersionExtension } from "./EditorVersionExtension";
|
||||
|
||||
jest.mock("@server/env", () => ({
|
||||
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
|
||||
}));
|
||||
|
||||
describe("ConnectionLimitExtension", () => {
|
||||
let server: typeof Server;
|
||||
let extension: ConnectionLimitExtension;
|
||||
const port = 12345;
|
||||
const url = `ws://localhost:${port}`;
|
||||
const documentName = "test";
|
||||
|
||||
beforeEach(async () => {
|
||||
extension = new ConnectionLimitExtension();
|
||||
server = Server.configure({
|
||||
port,
|
||||
extensions: [extension, new EditorVersionExtension()],
|
||||
});
|
||||
await server.listen();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.destroy();
|
||||
});
|
||||
|
||||
const getConnections = () =>
|
||||
extension.connectionsByDocument.get(documentName)?.size ?? 0;
|
||||
|
||||
const createWebSocket = (editorVersion = EDITOR_VERSION) =>
|
||||
new Promise<WebSocket>((resolve, reject) => {
|
||||
const ws = new WebSocket(
|
||||
`${url}/${documentName}?editorVersion=${editorVersion}`
|
||||
);
|
||||
ws.on("open", () => resolve(ws));
|
||||
ws.on("error", reject);
|
||||
});
|
||||
|
||||
it("should allow connections within limit", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
const ws2 = await createWebSocket();
|
||||
|
||||
expect(ws1.readyState).toBe(WebSocket.OPEN);
|
||||
expect(ws2.readyState).toBe(WebSocket.OPEN);
|
||||
expect(getConnections()).toBe(2);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
|
||||
it("should close connections exceeding limit", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
const ws2 = await createWebSocket();
|
||||
|
||||
const ws3 = await createWebSocket();
|
||||
await sleep(250);
|
||||
|
||||
expect(ws3.readyState).toBe(WebSocket.CLOSED);
|
||||
expect(ws2.readyState).toBe(WebSocket.OPEN);
|
||||
expect(ws1.readyState).toBe(WebSocket.OPEN);
|
||||
expect(getConnections()).toBe(2);
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle connections closed by other extensions", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
|
||||
// Create a connection that will be closed by the EditorVersionExtension
|
||||
const ws2 = await createWebSocket("1.0.0");
|
||||
|
||||
ws1.close();
|
||||
ws2.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
|
||||
it("should allow new connection after disconnect", async () => {
|
||||
const ws1 = await createWebSocket();
|
||||
const ws2 = await createWebSocket();
|
||||
|
||||
ws1.close();
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(1);
|
||||
|
||||
const ws3 = await createWebSocket();
|
||||
expect(ws3.readyState).toBe(WebSocket.OPEN);
|
||||
expect(getConnections()).toBe(2);
|
||||
|
||||
ws2.close();
|
||||
ws3.close();
|
||||
|
||||
await sleep(250);
|
||||
expect(getConnections()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
Extension,
|
||||
connectedPayload,
|
||||
onConnectPayload,
|
||||
onDisconnectPayload,
|
||||
} from "@hocuspocus/server";
|
||||
import pluralize from "pluralize";
|
||||
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -14,7 +16,7 @@ export class ConnectionLimitExtension implements Extension {
|
||||
/**
|
||||
* Map of documentId -> connection count
|
||||
*/
|
||||
connectionsByDocument: Map<string, Set<string>> = new Map();
|
||||
public connectionsByDocument: Map<string, Set<string>> = new Map();
|
||||
|
||||
/**
|
||||
* On disconnect hook
|
||||
@@ -34,23 +36,30 @@ export class ConnectionLimitExtension implements Extension {
|
||||
}
|
||||
}
|
||||
|
||||
const connectionCount = connections?.size ?? 0;
|
||||
Logger.debug(
|
||||
"multiplayer",
|
||||
`${connections?.size} connections to "${documentName}"`
|
||||
`${connectionCount} ${pluralize(
|
||||
"connection",
|
||||
connectionCount
|
||||
)} to "${documentName}"`
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* On connect hook
|
||||
* onConnect hook is called when a new connection has been established.
|
||||
* This is where we can check if the document has reached the maximum number of
|
||||
* connections and reject the connection if it has.
|
||||
*
|
||||
* @param data The connect payload
|
||||
* @returns Promise, resolving will allow the connection, rejecting will drop it
|
||||
* @param data The onConnect payload
|
||||
* @returns Promise, resolving will allow the connection, rejecting will drop.
|
||||
*/
|
||||
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
|
||||
onConnect({ documentName }: withContext<onConnectPayload>) {
|
||||
const connections =
|
||||
this.connectionsByDocument.get(documentName) || new Set();
|
||||
|
||||
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
|
||||
Logger.info(
|
||||
"multiplayer",
|
||||
@@ -61,12 +70,30 @@ export class ConnectionLimitExtension implements Extension {
|
||||
return Promise.reject(TooManyConnections);
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected hook is called after a new connection has been established.
|
||||
* We can safely update the connection count for the document.
|
||||
*
|
||||
* @param data The onConnect payload
|
||||
* @returns Promise
|
||||
*/
|
||||
connected({ documentName, socketId }: withContext<connectedPayload>) {
|
||||
const connections =
|
||||
this.connectionsByDocument.get(documentName) || new Set();
|
||||
|
||||
connections.add(socketId);
|
||||
this.connectionsByDocument.set(documentName, connections);
|
||||
const connectionCount = connections.size ?? 0;
|
||||
|
||||
Logger.debug(
|
||||
"multiplayer",
|
||||
`${connections.size} connections to "${documentName}"`
|
||||
`${connectionCount} ${pluralize(
|
||||
"connection",
|
||||
connectionCount
|
||||
)} to "${documentName}"`
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
|
||||
@@ -264,7 +264,6 @@ describe("accountProvisioner", () => {
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: existingTeam.name,
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
@@ -308,7 +307,6 @@ describe("accountProvisioner", () => {
|
||||
avatarUrl: faker.internet.avatar(),
|
||||
},
|
||||
team: {
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
subdomain: faker.internet.domainWord(),
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ type Props = {
|
||||
*/
|
||||
teamId?: string;
|
||||
/** The displayed name of the team */
|
||||
name: string;
|
||||
name?: string;
|
||||
/** The domain name from the email of the user logging in */
|
||||
domain?: string;
|
||||
/** The preferred subdomain to provision for the team if not yet created */
|
||||
@@ -92,6 +92,7 @@ async function accountProvisioner({
|
||||
|
||||
try {
|
||||
result = await teamProvisioner({
|
||||
name: "Wiki",
|
||||
...teamParams,
|
||||
authenticationProvider: authenticationProviderParams,
|
||||
ip,
|
||||
|
||||
@@ -3,7 +3,6 @@ import slugify from "slugify";
|
||||
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { Team, Event } from "@server/models";
|
||||
import { generateAvatarUrl } from "@server/utils/avatars";
|
||||
|
||||
type Props = {
|
||||
/** The displayed name of the team */
|
||||
@@ -29,20 +28,14 @@ type Props = {
|
||||
|
||||
async function teamCreator({
|
||||
name,
|
||||
domain,
|
||||
subdomain,
|
||||
avatarUrl,
|
||||
authenticationProviders,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Team> {
|
||||
// If the service did not provide a logo/avatar then we attempt to generate
|
||||
// one via ClearBit, or fallback to colored initials in worst case scenario
|
||||
if (!avatarUrl || !avatarUrl.startsWith("http")) {
|
||||
avatarUrl = await generateAvatarUrl({
|
||||
domain,
|
||||
id: subdomain,
|
||||
});
|
||||
if (!avatarUrl?.startsWith("http")) {
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
const team = await Team.create(
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("userProvisioner", () => {
|
||||
|
||||
it("should prefer isAdmin argument over defaultUserRole", async () => {
|
||||
const team = await buildTeam({
|
||||
defaultUserRole: "viewer",
|
||||
defaultUserRole: UserRole.Viewer,
|
||||
});
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
|
||||
@@ -86,7 +86,8 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
}
|
||||
return;
|
||||
},
|
||||
30
|
||||
30,
|
||||
10000
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.addColumn(
|
||||
"imports",
|
||||
"error",
|
||||
{
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await queryInterface.addColumn(
|
||||
"import_tasks",
|
||||
"error",
|
||||
{
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async transaction => {
|
||||
await queryInterface.removeColumn("imports", "error", { transaction });
|
||||
await queryInterface.removeColumn("import_tasks", "error", {
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable lines-between-class-members */
|
||||
import fractionalIndex from "fractional-index";
|
||||
import find from "lodash/find";
|
||||
import findIndex from "lodash/findIndex";
|
||||
import remove from "lodash/remove";
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
EmptyResultError,
|
||||
type CreateOptions,
|
||||
type UpdateOptions,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Sequelize,
|
||||
@@ -32,6 +35,8 @@ import {
|
||||
BeforeDestroy,
|
||||
IsDate,
|
||||
AllowNull,
|
||||
BeforeCreate,
|
||||
BeforeUpdate,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type { CollectionSort, ProsemirrorData } from "@shared/types";
|
||||
@@ -41,7 +46,9 @@ import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import slugify from "@shared/utils/slugify";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import { generateUrlId } from "@server/utils/url";
|
||||
import { ValidateIndex } from "@server/validation";
|
||||
import Document from "./Document";
|
||||
import FileOperation from "./FileOperation";
|
||||
import Group from "./Group";
|
||||
@@ -217,8 +224,8 @@ class Collection extends ParanoidModel<
|
||||
color: string | null;
|
||||
|
||||
@Length({
|
||||
max: 256,
|
||||
msg: `index must be 256 characters or less`,
|
||||
max: ValidateIndex.maxLength,
|
||||
msg: `index must be ${ValidateIndex.maxLength} characters or less`,
|
||||
})
|
||||
@Column
|
||||
index: string | null;
|
||||
@@ -324,6 +331,30 @@ class Collection extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async setIndex(model: Collection, options: CreateOptions<Collection>) {
|
||||
if (model.index) {
|
||||
model.index = await removeIndexCollision(model.teamId, model.index, {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const firstCollectionForTeam = await this.findOne({
|
||||
where: {
|
||||
teamId: model.teamId,
|
||||
},
|
||||
order: [
|
||||
// using LC_COLLATE:"C" because we need byte order to drive the sorting
|
||||
Sequelize.literal('"collection"."index" collate "C"'),
|
||||
["updatedAt", "DESC"],
|
||||
],
|
||||
...options,
|
||||
});
|
||||
|
||||
model.index = fractionalIndex(null, firstCollectionForTeam?.index ?? null);
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static async onAfterCreate(
|
||||
model: Collection,
|
||||
@@ -343,6 +374,18 @@ class Collection extends ParanoidModel<
|
||||
});
|
||||
}
|
||||
|
||||
@BeforeUpdate
|
||||
static async checkIndex(
|
||||
model: Collection,
|
||||
options: UpdateOptions<Collection>
|
||||
) {
|
||||
if (model.index && model.changed("index")) {
|
||||
model.index = await removeIndexCollision(model.teamId, model.index, {
|
||||
transaction: options.transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => FileOperation, "importId")
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildUser,
|
||||
buildGuestUser,
|
||||
} from "@server/test/factories";
|
||||
import Collection from "./Collection";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -78,6 +79,34 @@ describe("#delete", () => {
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should soft delete archived document in an archived collection", async () => {
|
||||
const user = await buildUser();
|
||||
const collection = await buildCollection({
|
||||
archivedAt: new Date(),
|
||||
createdById: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
archivedAt: new Date(),
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
await collection.addDocumentToStructure(document, 0);
|
||||
|
||||
await document.delete(user);
|
||||
const [newDocument, newCollection] = await Promise.all([
|
||||
Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
}),
|
||||
Collection.findByPk(collection.id),
|
||||
]);
|
||||
|
||||
expect(newDocument?.lastModifiedById).toEqual(user.id);
|
||||
expect(newDocument?.deletedAt).toBeTruthy();
|
||||
expect(newCollection?.documentStructure).toEqual([]);
|
||||
});
|
||||
|
||||
it("should delete draft without collection", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDraftDocument();
|
||||
|
||||
@@ -1112,18 +1112,26 @@ class Document extends ArchivableModel<
|
||||
// Delete a document, archived or otherwise.
|
||||
delete = (user: User) =>
|
||||
this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
if (!this.archivedAt && !this.template && this.collectionId) {
|
||||
// delete any children and remove from the document structure
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
let deleted = false;
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.findByPk(this.collectionId!, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
});
|
||||
await collection?.deleteDocument(this, { transaction });
|
||||
} else {
|
||||
|
||||
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
|
||||
await collection?.deleteDocument(this, { transaction });
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
await this.destroy({
|
||||
transaction,
|
||||
});
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
this.lastModifiedById = user.id;
|
||||
|
||||
@@ -60,6 +60,9 @@ class Import<T extends ImportableIntegrationService> extends ParanoidModel<
|
||||
@Column(DataType.INTEGER)
|
||||
documentCount: number;
|
||||
|
||||
@Column
|
||||
error: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Integration, "integrationId")
|
||||
|
||||
@@ -45,6 +45,9 @@ class ImportTask<T extends ImportableIntegrationService> extends IdModel<
|
||||
@Column(DataType.JSONB)
|
||||
output: ImportTaskOutput | null;
|
||||
|
||||
@Column
|
||||
error: string | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => Import, "importId")
|
||||
|
||||
@@ -9,7 +9,7 @@ import NotificationHelper from "./NotificationHelper";
|
||||
|
||||
describe("NotificationHelper", () => {
|
||||
describe("getCommentNotificationRecipients", () => {
|
||||
it("should return users who have notification enabled for comment creation and are subscribed to the document in case of parent comment", async () => {
|
||||
it("should only return users who have notification enabled for comment creation and are subscribed to the document in case of new thread", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
@@ -54,7 +54,7 @@ describe("NotificationHelper", () => {
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
});
|
||||
|
||||
it("should return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
it("should only return users who have notification enabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
@@ -112,32 +112,104 @@ describe("NotificationHelper", () => {
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentNotificationRecipients", () => {
|
||||
it("should return all users who have notification enabled for the event", async () => {
|
||||
it("should not return users who have notification disabled for comment creation and are in the thread in case of child comment", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const notificationEnabledUser = await buildUser({
|
||||
const notificationEnabledUserInThread = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: false },
|
||||
});
|
||||
const notificationEnabledUserNotInThread = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
const notificationDisabledUser = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: {
|
||||
[NotificationEventType.CreateComment]: false,
|
||||
},
|
||||
});
|
||||
await Promise.all([
|
||||
buildSubscription({
|
||||
userId: documentAuthor.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: notificationEnabledUserInThread.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: notificationEnabledUserNotInThread.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildSubscription({
|
||||
userId: notificationDisabledUser.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
]);
|
||||
const parentComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: notificationEnabledUserInThread.id,
|
||||
});
|
||||
const childComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: documentAuthor.id,
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
childComment,
|
||||
childComment.createdById
|
||||
);
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUser.id);
|
||||
expect(recipients.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return users who have notification enabled and are in the thread but not explicitly subscribed to document", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: documentAuthor.id,
|
||||
teamId: documentAuthor.teamId,
|
||||
});
|
||||
const notificationEnabledUserInThread = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.CreateComment]: true },
|
||||
});
|
||||
await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: {
|
||||
[NotificationEventType.CreateComment]: false,
|
||||
},
|
||||
});
|
||||
const parentComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: notificationEnabledUserInThread.id,
|
||||
});
|
||||
const childComment = await buildComment({
|
||||
documentId: document.id,
|
||||
userId: documentAuthor.id,
|
||||
parentCommentId: parentComment.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getCommentNotificationRecipients(
|
||||
document,
|
||||
childComment,
|
||||
childComment.createdById
|
||||
);
|
||||
|
||||
expect(recipients.length).toEqual(1);
|
||||
expect(recipients[0].id).toEqual(notificationEnabledUserInThread.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentNotificationRecipients", () => {
|
||||
it("should return users who have subscribed to the document", async () => {
|
||||
const documentAuthor = await buildUser();
|
||||
const document = await buildDocument({
|
||||
@@ -150,11 +222,17 @@ describe("NotificationHelper", () => {
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const deletedUser = await buildUser({ teamId: document.teamId });
|
||||
await buildSubscription({
|
||||
userId: deletedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
await deletedUser.destroy();
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
@@ -173,12 +251,15 @@ describe("NotificationHelper", () => {
|
||||
userId: subscribedUser.id,
|
||||
collectionId: document.collectionId!,
|
||||
});
|
||||
await buildSubscription({
|
||||
userId: subscribedUser.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
@@ -216,7 +297,6 @@ describe("NotificationHelper", () => {
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
@@ -235,20 +315,19 @@ describe("NotificationHelper", () => {
|
||||
});
|
||||
const notificationEnabledUser = await buildUser({
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
notificationSettings: { [NotificationEventType.PublishDocument]: true },
|
||||
});
|
||||
// suspended user
|
||||
await buildUser({
|
||||
suspendedAt: new Date(),
|
||||
teamId: document.teamId,
|
||||
notificationSettings: { [NotificationEventType.UpdateDocument]: true },
|
||||
notificationSettings: { [NotificationEventType.PublishDocument]: true },
|
||||
});
|
||||
|
||||
const recipients =
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: false,
|
||||
notificationType: NotificationEventType.PublishDocument,
|
||||
actorId: documentAuthor.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
NotificationEventType,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
Comment,
|
||||
View,
|
||||
} from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import { ProsemirrorHelper } from "./ProsemirrorHelper";
|
||||
|
||||
export default class NotificationHelper {
|
||||
@@ -60,18 +61,12 @@ export default class NotificationHelper {
|
||||
comment: Comment,
|
||||
actorId: string
|
||||
): Promise<User[]> => {
|
||||
let recipients = await this.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.CreateComment,
|
||||
onlySubscribers: !comment.parentCommentId,
|
||||
actorId,
|
||||
});
|
||||
let recipients: User[];
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(NotificationEventType.CreateComment)
|
||||
);
|
||||
|
||||
if (recipients.length > 0 && comment.parentCommentId) {
|
||||
// If this is a reply to another comment, we want to notify all users
|
||||
// that are involved in the thread of this comment (i.e. the original
|
||||
// comment and all replies to it).
|
||||
if (comment.parentCommentId) {
|
||||
const contextComments = await Comment.findAll({
|
||||
attributes: ["createdById", "data"],
|
||||
where: {
|
||||
@@ -95,13 +90,37 @@ export default class NotificationHelper {
|
||||
const userIdsInThread = uniq([
|
||||
...createdUserIdsInThread,
|
||||
...mentionedUserIdsInThread,
|
||||
]);
|
||||
recipients = recipients.filter((r) => userIdsInThread.includes(r.id));
|
||||
]).filter((userId) => userId !== actorId);
|
||||
|
||||
recipients = await User.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: userIdsInThread,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(NotificationEventType.CreateComment)
|
||||
);
|
||||
} else {
|
||||
recipients = await this.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.CreateComment,
|
||||
actorId,
|
||||
// We will check below, this just prevents duplicate queries
|
||||
disableAccessCheck: true,
|
||||
});
|
||||
}
|
||||
|
||||
const filtered: User[] = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.isSuspended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If this recipient has viewed the document since the comment was made
|
||||
// then we can avoid sending them a useless notification, yay.
|
||||
const view = await View.findOne({
|
||||
@@ -119,7 +138,13 @@ export default class NotificationHelper {
|
||||
"processor",
|
||||
`suppressing notification to ${recipient.id} because doc viewed`
|
||||
);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the recipient has access to the collection this document is in. Just
|
||||
// because they are subscribed doesn't mean they still have access to read
|
||||
// the document.
|
||||
if (await canUserAccessDocument(recipient, document.id)) {
|
||||
filtered.push(recipient);
|
||||
}
|
||||
}
|
||||
@@ -132,74 +157,78 @@ export default class NotificationHelper {
|
||||
*
|
||||
* @param document The document to get recipients for.
|
||||
* @param notificationType The notification type for which to find the recipients.
|
||||
* @param onlySubscribers Whether to consider only the users who have active subscription to the document.
|
||||
* @param actorId The id of the user that performed the action.
|
||||
* @param disableAccessCheck Whether to disable the access check for the document.
|
||||
* @returns A list of recipients
|
||||
*/
|
||||
public static getDocumentNotificationRecipients = async ({
|
||||
document,
|
||||
notificationType,
|
||||
onlySubscribers,
|
||||
actorId,
|
||||
disableAccessCheck = false,
|
||||
}: {
|
||||
document: Document;
|
||||
notificationType: NotificationEventType;
|
||||
onlySubscribers: boolean;
|
||||
actorId: string;
|
||||
disableAccessCheck?: boolean;
|
||||
}): Promise<User[]> => {
|
||||
// First find all the users that have notifications enabled for this event
|
||||
// type at all and aren't the one that performed the action.
|
||||
let recipients = await User.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
},
|
||||
});
|
||||
let recipients: User[];
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
recipient.subscribedToEventType(notificationType)
|
||||
);
|
||||
|
||||
// Filter further to only those that have a subscription to the document…
|
||||
if (onlySubscribers) {
|
||||
const subscriptions = await Subscription.findAll({
|
||||
attributes: ["userId"],
|
||||
if (notificationType === NotificationEventType.PublishDocument) {
|
||||
recipients = await User.findAll({
|
||||
where: {
|
||||
userId: recipients.map((recipient) => recipient.id),
|
||||
id: {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
teamId: document.teamId,
|
||||
notificationSettings: {
|
||||
[notificationType]: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const subscriptions = await Subscription.findAll({
|
||||
where: {
|
||||
userId: {
|
||||
[Op.ne]: actorId,
|
||||
},
|
||||
event: SubscriptionType.Document,
|
||||
[Op.or]: [
|
||||
{ collectionId: document.collectionId },
|
||||
{ documentId: document.id },
|
||||
],
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const subscribedUserIds = subscriptions.map(
|
||||
(subscription) => subscription.userId
|
||||
);
|
||||
|
||||
recipients = recipients.filter((recipient) =>
|
||||
subscribedUserIds.includes(recipient.id)
|
||||
recipients = uniqBy(
|
||||
subscriptions.map((s) => s.user),
|
||||
(user) => user.id
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = [];
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.email || recipient.isSuspended) {
|
||||
if (
|
||||
recipient.isSuspended ||
|
||||
!recipient.subscribedToEventType(notificationType)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check the recipient has access to the collection this document is in. Just
|
||||
// because they are subscribed doesn't mean they still have access to read
|
||||
// the document.
|
||||
const doc = await Document.findByPk(document.id, {
|
||||
userId: recipient.id,
|
||||
});
|
||||
|
||||
if (can(recipient, "read", doc)) {
|
||||
if (
|
||||
disableAccessCheck ||
|
||||
(await canUserAccessDocument(recipient, document.id))
|
||||
) {
|
||||
filtered.push(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import compact from "lodash/compact";
|
||||
import flatten from "lodash/flatten";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import isMatch from "lodash/isMatch";
|
||||
import uniq from "lodash/uniq";
|
||||
import { Node, DOMSerializer, Fragment, Mark } from "prosemirror-model";
|
||||
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
import { MentionType, ProsemirrorData } from "@shared/types";
|
||||
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
|
||||
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isRTL } from "@shared/utils/rtl";
|
||||
@@ -43,6 +42,8 @@ export type MentionAttrs = {
|
||||
modelId: string;
|
||||
actorId: string | undefined;
|
||||
id: string;
|
||||
href?: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
};
|
||||
|
||||
@trace()
|
||||
@@ -61,47 +62,7 @@ export class ProsemirrorHelper {
|
||||
);
|
||||
}
|
||||
|
||||
let node = parser.parse(input);
|
||||
|
||||
// in the editor embeds are created at runtime by converting links into
|
||||
// embeds where they match.Because we're converting to a CRDT structure on
|
||||
// the server we need to mimic this behavior.
|
||||
function urlsToEmbeds(node: Node): Node {
|
||||
if (node.type.name === "paragraph") {
|
||||
for (const textNode of node.content.content) {
|
||||
for (const embed of embeds) {
|
||||
if (
|
||||
textNode.text &&
|
||||
textNode.marks.some(
|
||||
(m: Mark) =>
|
||||
m.type.name === "link" && m.attrs.href === textNode.text
|
||||
) &&
|
||||
embed.matcher(textNode.text)
|
||||
) {
|
||||
return schema.nodes.embed.createAndFill({
|
||||
href: textNode.text,
|
||||
}) as Node;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.content) {
|
||||
const contentAsArray =
|
||||
node.content instanceof Fragment
|
||||
? node.content.content
|
||||
: node.content;
|
||||
// @ts-expect-error content
|
||||
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node) {
|
||||
node = urlsToEmbeds(node);
|
||||
}
|
||||
|
||||
const node = parser.parse(input);
|
||||
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
|
||||
}
|
||||
|
||||
@@ -235,7 +196,7 @@ export class ProsemirrorHelper {
|
||||
node.descendants((childNode: Node) => {
|
||||
if (
|
||||
childNode.type.name === "mention" &&
|
||||
isEqual(childNode.attrs, mention)
|
||||
isMatch(childNode.attrs, mention)
|
||||
) {
|
||||
foundMention = true;
|
||||
return false;
|
||||
|
||||
@@ -2,7 +2,7 @@ import invariant from "invariant";
|
||||
import filter from "lodash/filter";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { Collection, User, Team } from "@server/models";
|
||||
import { allow, can } from "./cancan";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
|
||||
|
||||
allow(User, "createCollection", Team, (actor, team) =>
|
||||
@@ -67,15 +67,6 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, "export", Collection, (actor, collection) =>
|
||||
and(
|
||||
//
|
||||
can(actor, "read", collection),
|
||||
!actor.isViewer,
|
||||
!actor.isGuest
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "share", Collection, (user, collection) => {
|
||||
if (
|
||||
!collection ||
|
||||
@@ -161,7 +152,7 @@ allow(
|
||||
}
|
||||
);
|
||||
|
||||
allow(User, ["update", "archive"], Collection, (user, collection) =>
|
||||
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
|
||||
and(
|
||||
!!collection,
|
||||
!!collection?.isActive,
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function presentImport(
|
||||
service: importModel.service,
|
||||
state: importModel.state,
|
||||
documentCount: importModel.documentCount,
|
||||
error: importModel.error,
|
||||
createdBy: presentUser(importModel.createdBy),
|
||||
createdById: importModel.createdById,
|
||||
createdAt: importModel.createdAt,
|
||||
|
||||
@@ -1,44 +1,85 @@
|
||||
import { Op } from "sequelize";
|
||||
import { GroupUser } from "@server/models";
|
||||
import { DocumentGroupEvent, DocumentUserEvent, Event } from "@server/types";
|
||||
import DocumentSubscriptionTask from "../tasks/DocumentSubscriptionTask";
|
||||
import {
|
||||
CollectionGroupEvent,
|
||||
CollectionUserEvent,
|
||||
DocumentGroupEvent,
|
||||
DocumentUserEvent,
|
||||
Event,
|
||||
} from "@server/types";
|
||||
import CollectionSubscriptionRemoveUserTask from "../tasks/CollectionSubscriptionRemoveUserTask";
|
||||
import DocumentSubscriptionRemoveUserTask from "../tasks/DocumentSubscriptionRemoveUserTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
type ReceivedEvent =
|
||||
| CollectionUserEvent
|
||||
| CollectionGroupEvent
|
||||
| DocumentUserEvent
|
||||
| DocumentGroupEvent;
|
||||
|
||||
export default class DocumentSubscriptionProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = [
|
||||
"collections.remove_user",
|
||||
"collections.remove_group",
|
||||
"documents.remove_user",
|
||||
"documents.remove_group",
|
||||
];
|
||||
|
||||
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
|
||||
async perform(event: ReceivedEvent) {
|
||||
switch (event.name) {
|
||||
case "collections.remove_user": {
|
||||
await CollectionSubscriptionRemoveUserTask.schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
case "collections.remove_group":
|
||||
return this.handleRemoveGroupFromCollection(event);
|
||||
|
||||
case "documents.remove_user": {
|
||||
await DocumentSubscriptionTask.schedule(event);
|
||||
await DocumentSubscriptionRemoveUserTask.schedule(event);
|
||||
return;
|
||||
}
|
||||
|
||||
case "documents.remove_group":
|
||||
return this.handleGroup(event);
|
||||
return this.handleRemoveGroupFromDocument(event);
|
||||
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGroup(event: DocumentGroupEvent) {
|
||||
private async handleRemoveGroupFromCollection(event: CollectionGroupEvent) {
|
||||
await GroupUser.findAllInBatches<GroupUser>(
|
||||
{
|
||||
where: {
|
||||
groupId: event.modelId,
|
||||
userId: {
|
||||
[Op.ne]: event.actorId,
|
||||
},
|
||||
},
|
||||
batchLimit: 10,
|
||||
},
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionTask.schedule({
|
||||
CollectionSubscriptionRemoveUserTask.schedule({
|
||||
...event,
|
||||
name: "collections.remove_user",
|
||||
userId: groupUser.userId,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async handleRemoveGroupFromDocument(event: DocumentGroupEvent) {
|
||||
await GroupUser.findAllInBatches<GroupUser>(
|
||||
{
|
||||
where: {
|
||||
groupId: event.modelId,
|
||||
},
|
||||
batchLimit: 10,
|
||||
},
|
||||
async (groupUsers) => {
|
||||
await Promise.all(
|
||||
groupUsers.map((groupUser) =>
|
||||
DocumentSubscriptionRemoveUserTask.schedule({
|
||||
...event,
|
||||
name: "documents.remove_user",
|
||||
userId: groupUser.userId,
|
||||
|
||||
@@ -6,7 +6,6 @@ import ExportJSONTask from "../tasks/ExportJSONTask";
|
||||
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
|
||||
import ImportJSONTask from "../tasks/ImportJSONTask";
|
||||
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
|
||||
import ImportNotionTask from "../tasks/ImportNotionTask";
|
||||
import BaseProcessor from "./BaseProcessor";
|
||||
|
||||
export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||
@@ -25,11 +24,6 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.Notion:
|
||||
await ImportNotionTask.schedule({
|
||||
fileOperationId: event.modelId,
|
||||
});
|
||||
break;
|
||||
case FileOperationFormat.JSON:
|
||||
await ImportJSONTask.schedule({
|
||||
fileOperationId: event.modelId,
|
||||
|
||||
@@ -3,7 +3,12 @@ import chunk from "lodash/chunk";
|
||||
import keyBy from "lodash/keyBy";
|
||||
import truncate from "lodash/truncate";
|
||||
import { Fragment, Node } from "prosemirror-model";
|
||||
import { CreateOptions, CreationAttributes, Transaction } from "sequelize";
|
||||
import {
|
||||
CreateOptions,
|
||||
CreationAttributes,
|
||||
Transaction,
|
||||
UniqueConstraintError,
|
||||
} from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { ImportInput, ImportTaskInput } from "@shared/schema";
|
||||
@@ -49,33 +54,46 @@ export default abstract class ImportsProcessor<
|
||||
* @param event The import event
|
||||
*/
|
||||
public async perform(event: ImportEvent) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
try {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.canProcess(importModel) ||
|
||||
importModel.state === ImportState.Errored ||
|
||||
importModel.state === ImportState.Canceled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "imports.create":
|
||||
return this.onCreation(importModel, transaction);
|
||||
|
||||
case "imports.processed":
|
||||
return this.onProcessed(importModel, transaction);
|
||||
|
||||
case "imports.delete":
|
||||
return this.onDeletion(importModel, event, transaction);
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!this.canProcess(importModel) ||
|
||||
importModel.state === ImportState.Errored ||
|
||||
importModel.state === ImportState.Canceled
|
||||
) {
|
||||
return;
|
||||
} catch (err) {
|
||||
if (event.name !== "imports.delete" && err instanceof Error) {
|
||||
const importModel = await Import.findByPk<Import<T>>(event.modelId, {
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
});
|
||||
importModel.error = truncate(err.message, { length: 255 });
|
||||
await importModel.save();
|
||||
}
|
||||
|
||||
switch (event.name) {
|
||||
case "imports.create":
|
||||
return this.onCreation(importModel, transaction);
|
||||
|
||||
case "imports.processed":
|
||||
return this.onProcessed(importModel, transaction);
|
||||
|
||||
case "imports.delete":
|
||||
return this.onDeletion(importModel, event, transaction);
|
||||
}
|
||||
});
|
||||
throw err; // throw error for retry.
|
||||
}
|
||||
}
|
||||
|
||||
public async onFailed(event: ImportEvent) {
|
||||
@@ -141,44 +159,59 @@ export default abstract class ImportsProcessor<
|
||||
* @returns Promise that resolves when mapping and persistence is completed.
|
||||
*/
|
||||
private async onProcessed(importModel: Import<T>, transaction: Transaction) {
|
||||
const { collections } = await this.createCollectionsAndDocuments({
|
||||
importModel,
|
||||
transaction,
|
||||
});
|
||||
|
||||
// Once all collections and documents are created, update collection's document structure.
|
||||
// This ensures the root documents have the whole subtree available in the structure.
|
||||
for (const collection of collections) {
|
||||
await Document.unscoped().findAllInBatches<Document>(
|
||||
{
|
||||
where: { parentDocumentId: null, collectionId: collection.id },
|
||||
order: [
|
||||
["createdAt", "DESC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
transaction,
|
||||
},
|
||||
async (documents) => {
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document, 0, {
|
||||
save: false,
|
||||
silent: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await collection.save({ silent: true, transaction });
|
||||
}
|
||||
|
||||
importModel.state = ImportState.Completed;
|
||||
await importModel.saveWithCtx(
|
||||
createContext({
|
||||
user: importModel.createdBy,
|
||||
try {
|
||||
const { collections } = await this.createCollectionsAndDocuments({
|
||||
importModel,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Once all collections and documents are created, update collection's document structure.
|
||||
// This ensures the root documents have the whole subtree available in the structure.
|
||||
for (const collection of collections) {
|
||||
await Document.unscoped().findAllInBatches<Document>(
|
||||
{
|
||||
where: { parentDocumentId: null, collectionId: collection.id },
|
||||
order: [
|
||||
["createdAt", "DESC"],
|
||||
["id", "ASC"],
|
||||
],
|
||||
transaction,
|
||||
},
|
||||
async (documents) => {
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document, 0, {
|
||||
save: false,
|
||||
silent: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await collection.save({ silent: true, transaction });
|
||||
}
|
||||
|
||||
importModel.state = ImportState.Completed;
|
||||
importModel.error = null; // unset any error from previous attempts.
|
||||
await importModel.saveWithCtx(
|
||||
createContext({
|
||||
user: importModel.createdBy,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UniqueConstraintError) {
|
||||
Logger.error(
|
||||
"ImportsProcessor persistence failed due to unique constraint error",
|
||||
err,
|
||||
{
|
||||
fields: err.fields,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +323,15 @@ export default abstract class ImportsProcessor<
|
||||
|
||||
const output = outputMap[externalId];
|
||||
|
||||
// Skip this item if it has no output (likely due to an error during processing)
|
||||
if (!output) {
|
||||
Logger.debug(
|
||||
"processor",
|
||||
`Skipping item with no output: ${externalId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const collectionItem = importInput[externalId];
|
||||
|
||||
const attachments = await Attachment.findAll({
|
||||
@@ -430,7 +472,7 @@ export default abstract class ImportsProcessor<
|
||||
importInput: Record<string, ImportInput<any>[number]>;
|
||||
actorId: string;
|
||||
}): ProsemirrorDoc {
|
||||
// special case when the doc content is empty
|
||||
// special case when the doc content is empty.
|
||||
if (!content.content.length) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { JobOptions } from "bull";
|
||||
import chunk from "lodash/chunk";
|
||||
import truncate from "lodash/truncate";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
import { Fragment, Node } from "prosemirror-model";
|
||||
import { Transaction, WhereOptions } from "sequelize";
|
||||
@@ -63,20 +64,29 @@ export default abstract class APIImportTask<
|
||||
return;
|
||||
}
|
||||
|
||||
switch (importTask.state) {
|
||||
case ImportTaskState.Created: {
|
||||
importTask.state = ImportTaskState.InProgress;
|
||||
importTask = await importTask.save();
|
||||
return await this.onProcess(importTask);
|
||||
try {
|
||||
switch (importTask.state) {
|
||||
case ImportTaskState.Created: {
|
||||
importTask.state = ImportTaskState.InProgress;
|
||||
importTask = await importTask.save();
|
||||
return await this.onProcess(importTask);
|
||||
}
|
||||
|
||||
case ImportTaskState.InProgress:
|
||||
return await this.onProcess(importTask);
|
||||
|
||||
case ImportTaskState.Completed:
|
||||
return await this.onCompletion(importTask);
|
||||
|
||||
default:
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
importTask.error = truncate(err.message, { length: 255 });
|
||||
await importTask.save();
|
||||
}
|
||||
|
||||
case ImportTaskState.InProgress:
|
||||
return await this.onProcess(importTask);
|
||||
|
||||
case ImportTaskState.Completed:
|
||||
return await this.onCompletion(importTask);
|
||||
|
||||
default:
|
||||
throw err; // throw error for retry.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +118,7 @@ export default abstract class APIImportTask<
|
||||
await importTask.save({ transaction });
|
||||
|
||||
const associatedImport = importTask.import;
|
||||
associatedImport.error = importTask.error; // copy error from ImportTask that caused the failure.
|
||||
associatedImport.state = ImportState.Errored;
|
||||
await associatedImport.saveWithCtx(
|
||||
createContext({
|
||||
@@ -155,10 +166,11 @@ export default abstract class APIImportTask<
|
||||
|
||||
importTask.output = taskOutputWithReplacements;
|
||||
importTask.state = ImportTaskState.Completed;
|
||||
importTask.error = null; // unset any error from previous attempts.
|
||||
await importTask.save({ transaction });
|
||||
|
||||
const associatedImport = importTask.import;
|
||||
associatedImport.documentCount += importTask.input.length;
|
||||
associatedImport.documentCount += taskOutputWithReplacements.length;
|
||||
await associatedImport.saveWithCtx(
|
||||
createContext({
|
||||
user: associatedImport.createdBy,
|
||||
|
||||
@@ -41,6 +41,7 @@ export default class CleanupOldImportsTask extends BaseTask<Props> {
|
||||
],
|
||||
batchLimit: 50,
|
||||
totalLimit: maxImportsPerTask,
|
||||
paranoid: false,
|
||||
},
|
||||
async (imports) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { SubscriptionType } from "@shared/types";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Collection, Subscription, User } from "@server/models";
|
||||
import { can } from "@server/policies";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { CollectionUserEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
export default class CollectionSubscriptionRemoveUserTask extends BaseTask<CollectionUserEvent> {
|
||||
public async perform(event: CollectionUserEvent) {
|
||||
const user = await User.findByPk(event.userId);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(event.collectionId);
|
||||
|
||||
if (can(user, "read", collection)) {
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Skip unsubscribing user ${user.id} as they have permission to the collection ${event.collectionId} through other means`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const subscription = await Subscription.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
collectionId: event.collectionId,
|
||||
event: SubscriptionType.Document,
|
||||
},
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
await subscription?.destroyWithCtx(
|
||||
createContext({
|
||||
user,
|
||||
authType: event.authType,
|
||||
ip: event.ip,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,16 +85,21 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
|
||||
)
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
for (const recipient of recipients) {
|
||||
await Notification.create({
|
||||
event: NotificationEventType.CreateComment,
|
||||
userId: recipient.id,
|
||||
actorId: comment.createdById,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
});
|
||||
}
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
for (const recipient of recipients) {
|
||||
await Notification.create(
|
||||
{
|
||||
event: NotificationEventType.CreateComment,
|
||||
userId: recipient.id,
|
||||
actorId: comment.createdById,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public get options() {
|
||||
|
||||
@@ -54,7 +54,6 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.PublishDocument,
|
||||
onlySubscribers: false,
|
||||
actorId: document.lastModifiedById,
|
||||
})
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
+2
-2
@@ -8,11 +8,11 @@ import { sequelize } from "@server/storage/database";
|
||||
import { DocumentUserEvent } from "@server/types";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
|
||||
export default class DocumentSubscriptionRemoveUserTask extends BaseTask<DocumentUserEvent> {
|
||||
public async perform(event: DocumentUserEvent) {
|
||||
const user = await User.findByPk(event.userId);
|
||||
|
||||
if (!user || event.name !== "documents.remove_user") {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,13 @@ export default class ErrorTimedOutImportsTask extends BaseTask<Props> {
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
importTask.state = ImportTaskState.Errored;
|
||||
importTask.error = "Timed out";
|
||||
await importTask.save({ transaction });
|
||||
|
||||
// this import could have been seen before in another import_task.
|
||||
if (!importsErrored[associatedImport.id]) {
|
||||
associatedImport.state = ImportState.Errored;
|
||||
associatedImport.error = "Timed out";
|
||||
await associatedImport.save({ transaction });
|
||||
importsErrored[associatedImport.id] = true;
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import path from "path";
|
||||
import { FileOperation } from "@server/models";
|
||||
import { buildFileOperation } from "@server/test/factories";
|
||||
import ImportNotionTask from "./ImportNotionTask";
|
||||
|
||||
describe("ImportNotionTask", () => {
|
||||
it("should import successfully from a Markdown export", async () => {
|
||||
const fileOperation = await buildFileOperation();
|
||||
Object.defineProperty(fileOperation, "handle", {
|
||||
get() {
|
||||
return {
|
||||
path: path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"test",
|
||||
"fixtures",
|
||||
"notion-markdown.zip"
|
||||
),
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
|
||||
|
||||
const props = {
|
||||
fileOperationId: fileOperation.id,
|
||||
};
|
||||
|
||||
const task = new ImportNotionTask();
|
||||
const response = await task.perform(props);
|
||||
|
||||
expect(response.collections.size).toEqual(2);
|
||||
expect(response.documents.size).toEqual(6);
|
||||
expect(response.attachments.size).toEqual(1);
|
||||
|
||||
// Check that the image url was replaced in the text with a redirect
|
||||
const attachments = Array.from(response.attachments.values());
|
||||
const documents = Array.from(response.documents.values());
|
||||
expect(documents.map((d) => d.text).join("")).toContain(
|
||||
attachments[0].redirectUrl
|
||||
);
|
||||
});
|
||||
|
||||
it("should import successfully from a HTML export", async () => {
|
||||
const fileOperation = await buildFileOperation();
|
||||
Object.defineProperty(fileOperation, "handle", {
|
||||
get() {
|
||||
return {
|
||||
path: path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"test",
|
||||
"fixtures",
|
||||
"notion-html.zip"
|
||||
),
|
||||
cleanup: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
|
||||
|
||||
const props = {
|
||||
fileOperationId: fileOperation.id,
|
||||
};
|
||||
|
||||
const task = new ImportNotionTask();
|
||||
const response = await task.perform(props);
|
||||
|
||||
expect(response.collections.size).toEqual(2);
|
||||
expect(response.documents.size).toEqual(6);
|
||||
expect(response.attachments.size).toEqual(4);
|
||||
|
||||
// Check that the image url was replaced in the text with a redirect
|
||||
const attachments = Array.from(response.attachments.values());
|
||||
const attachment = attachments.find((att) =>
|
||||
att.key.endsWith("Screen_Shot_2022-04-21_at_2.23.26_PM.png")
|
||||
);
|
||||
|
||||
const documents = Array.from(response.documents.values());
|
||||
expect(documents.map((d) => d.text).join("")).toContain(
|
||||
attachment?.redirectUrl
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,354 +0,0 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import compact from "lodash/compact";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import mime from "mime-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import documentImporter from "@server/commands/documentImporter";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { FileOperation, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
|
||||
import ImportTask, { StructuredImportData } from "./ImportTask";
|
||||
|
||||
export default class ImportNotionTask extends ImportTask {
|
||||
public async parseData(
|
||||
dirPath: string,
|
||||
fileOperation: FileOperation
|
||||
): Promise<StructuredImportData> {
|
||||
const tree = await ImportHelper.toFileTree(dirPath);
|
||||
if (!tree) {
|
||||
throw new Error("Could not find valid content in zip file");
|
||||
}
|
||||
|
||||
// New Notion exports have a single folder with the name of the export, we must skip this
|
||||
// folder and go directly to the children.
|
||||
let parsed;
|
||||
if (
|
||||
tree.children.length === 1 &&
|
||||
tree.children[0].children.find((child) => child.title === "index")
|
||||
) {
|
||||
parsed = await this.parseFileTree(
|
||||
fileOperation,
|
||||
tree.children[0].children.filter((child) => child.title !== "index")
|
||||
);
|
||||
} else {
|
||||
parsed = await this.parseFileTree(fileOperation, tree.children);
|
||||
}
|
||||
|
||||
if (parsed.documents.length === 0 && parsed.collections.length === 1) {
|
||||
const collection = parsed.collections[0];
|
||||
const collectionId = uuidv4();
|
||||
if (collection.description) {
|
||||
parsed.documents.push({
|
||||
title: collection.name,
|
||||
icon: collection.icon,
|
||||
color: collection.color,
|
||||
path: "",
|
||||
text: String(collection.description),
|
||||
id: collection.id,
|
||||
externalId: collection.externalId,
|
||||
mimeType: "text/html",
|
||||
collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
collection.name = "Notion";
|
||||
collection.icon = undefined;
|
||||
collection.color = undefined;
|
||||
collection.externalId = undefined;
|
||||
collection.description = undefined;
|
||||
collection.id = collectionId;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the file structure from zipAsFileTree into documents,
|
||||
* collections, and attachments.
|
||||
*
|
||||
* @param fileOperation The file operation
|
||||
* @param tree An array of FileTreeNode representing root files in the zip
|
||||
* @returns A StructuredImportData object
|
||||
*/
|
||||
private async parseFileTree(
|
||||
fileOperation: FileOperation,
|
||||
tree: FileTreeNode[]
|
||||
): Promise<StructuredImportData> {
|
||||
const user = await User.findByPk(fileOperation.userId, {
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
|
||||
const output: StructuredImportData = {
|
||||
collections: [],
|
||||
documents: [],
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
const parseNodeChildren = async (
|
||||
children: FileTreeNode[],
|
||||
collectionId: string,
|
||||
parentDocumentId?: string
|
||||
): Promise<void> => {
|
||||
await Promise.all(
|
||||
children.map(async (child) => {
|
||||
// Ignore the CSV's for databases upfront
|
||||
if (child.path.endsWith(".csv")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const match = child.title.match(this.NotionUUIDRegex);
|
||||
const name = child.title.replace(this.NotionUUIDRegex, "");
|
||||
const externalId = match ? match[0].trim() : undefined;
|
||||
|
||||
// If it's not a text file we're going to treat it as an attachment.
|
||||
const mimeType = mime.lookup(child.name);
|
||||
const isDocument =
|
||||
mimeType === "text/markdown" ||
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/html";
|
||||
|
||||
// If it's not a document and not a folder, treat it as an attachment
|
||||
if (!isDocument && mimeType) {
|
||||
output.attachments.push({
|
||||
id,
|
||||
name: child.name,
|
||||
path: child.path,
|
||||
mimeType,
|
||||
buffer: () => fs.readFile(child.path),
|
||||
externalId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.debug("task", `Processing ${name} as ${mimeType}`);
|
||||
|
||||
const { title, icon, text } = await sequelize.transaction(
|
||||
async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType: mimeType || "text/markdown",
|
||||
fileName: name,
|
||||
content:
|
||||
child.children.length > 0
|
||||
? ""
|
||||
: await fs.readFile(child.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
const existingDocumentIndex = output.documents.findIndex(
|
||||
(doc) => doc.externalId === externalId
|
||||
);
|
||||
|
||||
const existingDocument = output.documents[existingDocumentIndex];
|
||||
|
||||
// If there is an existing document with the same externalId that means
|
||||
// we've already parsed either a folder or a file referencing the same
|
||||
// document, as such we should merge.
|
||||
if (existingDocument) {
|
||||
if (existingDocument.text === "") {
|
||||
output.documents[existingDocumentIndex].text = text;
|
||||
}
|
||||
|
||||
await parseNodeChildren(
|
||||
child.children,
|
||||
collectionId,
|
||||
existingDocument.id
|
||||
);
|
||||
} else {
|
||||
output.documents.push({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
text,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
path: child.path,
|
||||
mimeType: mimeType || "text/markdown",
|
||||
externalId,
|
||||
});
|
||||
await parseNodeChildren(child.children, collectionId, id);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const replaceInternalLinksAndImages = (text: string) => {
|
||||
// Find if there are any images in this document
|
||||
const imagesInText = this.parseImages(text);
|
||||
|
||||
for (const image of imagesInText) {
|
||||
const name = path.basename(image.src);
|
||||
const attachment = output.attachments.find(
|
||||
(att) =>
|
||||
att.path.endsWith(image.src) ||
|
||||
encodeURI(att.path).endsWith(image.src)
|
||||
);
|
||||
|
||||
if (!attachment) {
|
||||
if (!image.src.startsWith("http")) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`Could not find referenced attachment with name ${name} and src ${image.src}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
text = text.replace(
|
||||
new RegExp(escapeRegExp(image.src), "g"),
|
||||
`<<${attachment.id}>>`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// With Notion's HTML import, images sometimes come wrapped in anchor tags
|
||||
// This isn't supported in Outline's editor, so we need to strip them.
|
||||
text = text.replace(/\[!\[([^[]+)]/g, "![]");
|
||||
|
||||
// Find if there are any links in this document pointing to other documents
|
||||
const internalLinksInText = this.parseInternalLinks(text);
|
||||
|
||||
// For each link update to the standardized format of <<documentId>>
|
||||
// instead of a relative or absolute URL within the original zip file.
|
||||
for (const link of internalLinksInText) {
|
||||
const doc = output.documents.find(
|
||||
(doc) => doc.externalId === link.externalId
|
||||
);
|
||||
|
||||
if (!doc) {
|
||||
Logger.info(
|
||||
"task",
|
||||
`Could not find referenced document with externalId ${link.externalId}`
|
||||
);
|
||||
} else {
|
||||
text = text.replace(link.href, `<<${doc.id}>>`);
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// All nodes in the root level should become collections
|
||||
for (const node of tree) {
|
||||
const match = node.title.match(this.NotionUUIDRegex);
|
||||
const name = node.title.replace(this.NotionUUIDRegex, "");
|
||||
const externalId = match ? match[0].trim() : undefined;
|
||||
const mimeType = mime.lookup(node.name);
|
||||
|
||||
const existingCollectionIndex = output.collections.findIndex(
|
||||
(collection) => collection.externalId === externalId
|
||||
);
|
||||
const existingCollection = output.collections[existingCollectionIndex];
|
||||
const collectionId = existingCollection?.id || uuidv4();
|
||||
let description;
|
||||
|
||||
// Root level docs become the descriptions of collections
|
||||
if (
|
||||
mimeType === "text/markdown" ||
|
||||
mimeType === "text/plain" ||
|
||||
mimeType === "text/html"
|
||||
) {
|
||||
const { text } = await sequelize.transaction(async (transaction) =>
|
||||
documentImporter({
|
||||
mimeType,
|
||||
fileName: name,
|
||||
content: await fs.readFile(node.path, "utf8"),
|
||||
user,
|
||||
ctx: createContext({ user, transaction }),
|
||||
})
|
||||
);
|
||||
|
||||
description = text;
|
||||
} else if (node.children.length > 0) {
|
||||
await parseNodeChildren(node.children, collectionId);
|
||||
} else {
|
||||
Logger.debug("task", `Unhandled file in zip: ${node.path}`, {
|
||||
fileOperationId: fileOperation.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingCollectionIndex !== -1) {
|
||||
if (description) {
|
||||
output.collections[existingCollectionIndex].description = description;
|
||||
}
|
||||
} else {
|
||||
output.collections.push({
|
||||
id: collectionId,
|
||||
name,
|
||||
description,
|
||||
externalId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const document of output.documents) {
|
||||
document.text = replaceInternalLinksAndImages(document.text);
|
||||
}
|
||||
|
||||
for (const collection of output.collections) {
|
||||
if (typeof collection.description === "string") {
|
||||
collection.description = replaceInternalLinksAndImages(
|
||||
collection.description
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts internal links from a markdown document, taking into account the
|
||||
* externalId of the document, which is part of the link title.
|
||||
*
|
||||
* @param text The markdown text to parse
|
||||
* @returns An array of internal links
|
||||
*/
|
||||
private parseInternalLinks(
|
||||
text: string
|
||||
): { title: string; href: string; externalId: string }[] {
|
||||
return compact(
|
||||
[...text.matchAll(this.NotionLinkRegex)].map((match) => ({
|
||||
title: match[1],
|
||||
href: match[2],
|
||||
externalId: match[3],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts images from the markdown document
|
||||
*
|
||||
* @param text The markdown text to parse
|
||||
* @returns An array of internal links
|
||||
*/
|
||||
private parseImages(text: string): { alt: string; src: string }[] {
|
||||
return compact(
|
||||
[...text.matchAll(this.ImageRegex)].map((match) => ({
|
||||
alt: match[1],
|
||||
src: match[2],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex to find markdown images of all types
|
||||
*/
|
||||
private ImageRegex =
|
||||
/!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<title>[^\][”]+)?”?\)/g;
|
||||
|
||||
/**
|
||||
* Regex to find markdown links containing ID's that look like UUID's with the
|
||||
* "-"'s removed, Notion's externalId format.
|
||||
*/
|
||||
private NotionLinkRegex = /\[([^[]+)]\((.*?([0-9a-fA-F]{32})\..*?)\)/g;
|
||||
|
||||
/**
|
||||
* Regex to find Notion document UUID's in the title of a document.
|
||||
*/
|
||||
private NotionUUIDRegex =
|
||||
/\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
||||
}
|
||||
@@ -76,7 +76,6 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
|
||||
await NotificationHelper.getDocumentNotificationRecipients({
|
||||
document,
|
||||
notificationType: NotificationEventType.UpdateDocument,
|
||||
onlySubscribers: true,
|
||||
actorId: document.lastModifiedById,
|
||||
})
|
||||
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
|
||||
|
||||
@@ -1327,6 +1327,32 @@ describe("#collections.create", () => {
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should ensure unique index across the team", async () => {
|
||||
const team = await buildTeam();
|
||||
const [adminA, adminB] = await Promise.all([
|
||||
buildAdmin({ teamId: team.id }),
|
||||
buildAdmin({ teamId: team.id }),
|
||||
]);
|
||||
|
||||
const resA = await server.post("/api/collections.create", {
|
||||
body: {
|
||||
token: adminA.getJwtToken(),
|
||||
name: "Test A",
|
||||
},
|
||||
});
|
||||
const resB = await server.post("/api/collections.create", {
|
||||
body: {
|
||||
token: adminB.getJwtToken(),
|
||||
name: "Test B",
|
||||
},
|
||||
});
|
||||
const [bodyA, bodyB] = await Promise.all([resA.json(), resB.json()]);
|
||||
|
||||
expect(resA.status).toEqual(200);
|
||||
expect(resB.status).toEqual(200);
|
||||
expect(bodyA.data.index).not.toEqual(bodyB.data.index);
|
||||
});
|
||||
|
||||
it("if index collision, should updated index of other collection", async () => {
|
||||
const user = await buildUser();
|
||||
const createdCollectionAResponse = await server.post(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import fractionalIndex from "fractional-index";
|
||||
import invariant from "invariant";
|
||||
import Router from "koa-router";
|
||||
import { Sequelize, Op, WhereOptions } from "sequelize";
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
import { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { collectionIndexing } from "@server/utils/indexing";
|
||||
import removeIndexCollision from "@server/utils/removeIndexCollision";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
@@ -55,23 +53,21 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsCreateReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { name, color, description, data, permission, sharing, icon, sort } =
|
||||
ctx.input.body;
|
||||
let { index } = ctx.input.body;
|
||||
const {
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
data,
|
||||
permission,
|
||||
sharing,
|
||||
icon,
|
||||
sort,
|
||||
index,
|
||||
} = ctx.input.body;
|
||||
|
||||
const { user } = ctx.state.auth;
|
||||
authorize(user, "createCollection", user.team);
|
||||
|
||||
if (index) {
|
||||
index = await removeIndexCollision(user.teamId, index, { transaction });
|
||||
} else {
|
||||
const first = await Collection.findFirstCollectionForUser(user, {
|
||||
attributes: ["id", "index"],
|
||||
transaction,
|
||||
});
|
||||
index = fractionalIndex(null, first ? first.index : null);
|
||||
}
|
||||
|
||||
const collection = Collection.build({
|
||||
name,
|
||||
content: data,
|
||||
@@ -601,6 +597,7 @@ router.post(
|
||||
createdById: user.id,
|
||||
},
|
||||
transaction,
|
||||
hooks: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -959,18 +956,16 @@ router.post(
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CollectionsMoveReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id } = ctx.input.body;
|
||||
let { index } = ctx.input.body;
|
||||
const { id, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const collection = await Collection.findByPk(id, {
|
||||
let collection = await Collection.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "move", collection);
|
||||
|
||||
index = await removeIndexCollision(user.teamId, index, { transaction });
|
||||
await collection.update(
|
||||
collection = await collection.update(
|
||||
{
|
||||
index,
|
||||
},
|
||||
@@ -982,14 +977,14 @@ router.post(
|
||||
name: "collections.move",
|
||||
collectionId: collection.id,
|
||||
data: {
|
||||
index,
|
||||
index: collection.index,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
data: {
|
||||
index,
|
||||
index: collection.index,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import queryString from "query-string";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
@@ -697,3 +698,40 @@ describe("#notifications.update_all", () => {
|
||||
expect(body.data.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#notifications.unsubscribe", () => {
|
||||
it("should allow unsubscribe with valid token", async () => {
|
||||
const user = await buildUser();
|
||||
const token = NotificationSettingsHelper.unsubscribeToken(
|
||||
user.id,
|
||||
NotificationEventType.UpdateDocument
|
||||
);
|
||||
|
||||
const res = await server.get(
|
||||
`/api/notifications.unsubscribe?userId=${user.id}&token=${token}&eventType=documents.update&follow=true`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"/settings/notifications?success"
|
||||
);
|
||||
|
||||
const events = (await user.reload()).notificationSettings;
|
||||
expect(events).not.toContain("documents.update");
|
||||
});
|
||||
|
||||
it("should not allow unsubscribe with invalid token", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
const res = await server.get(
|
||||
`/api/notifications.unsubscribe?userId=${user.id}&token=invalid-token&eventType=documents.update&follow=true`,
|
||||
{
|
||||
redirect: "manual",
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get("location")).toContain("?notice=invalid-auth");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user