mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f06c1d95fa | |||
| c9c5e86b72 | |||
| 84f46e0f96 | |||
| 3219cf7dbe | |||
| f00bec87d7 | |||
| 41c8d664b2 | |||
| 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 |
@@ -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,
|
||||
|
||||
@@ -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,7 +144,7 @@ jobs:
|
||||
yarn test --maxWorkers=2 $TESTFILES
|
||||
|
||||
bundle-size:
|
||||
needs: [build, types]
|
||||
needs: [build, types, changes]
|
||||
if: ${{ needs.changes.outputs.app == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -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 }}
|
||||
+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,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);
|
||||
@@ -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]
|
||||
|
||||
+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;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { ellipsis } from "@shared/styles";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
type Props = {
|
||||
issueTitle: string;
|
||||
open: boolean;
|
||||
onCreate: (source: IssueSource) => Promise<void>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const CreateIssueDialog = observer(
|
||||
({ issueTitle, open, onCreate, onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [isCreating, setCreating, unsetCreating] = useBoolean();
|
||||
const [selectedSource, selectSource] = React.useState<IssueSource>();
|
||||
|
||||
const {
|
||||
data: sources,
|
||||
loading,
|
||||
request,
|
||||
} = useRequest<IssueSource[]>(
|
||||
React.useCallback(async () => {
|
||||
try {
|
||||
const res = await client.post("/issues.list_sources");
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
toast.error(t("Couldn't load issue sources, try again?"));
|
||||
throw err;
|
||||
}
|
||||
}, [t])
|
||||
);
|
||||
|
||||
const handleCreateIssue = React.useCallback(async () => {
|
||||
setCreating();
|
||||
await onCreate(selectedSource!);
|
||||
unsetCreating();
|
||||
}, [selectedSource, onCreate, setCreating, unsetCreating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
void request();
|
||||
} else {
|
||||
selectSource(undefined);
|
||||
}
|
||||
}, [open, request]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("Create issue")}
|
||||
isOpen={open}
|
||||
onRequestClose={onClose}
|
||||
fullscreen={false}
|
||||
>
|
||||
<FlexContainer column>
|
||||
<ListContainer>
|
||||
{loading ? (
|
||||
"Loading..."
|
||||
) : !sources?.length ? (
|
||||
"No source available"
|
||||
) : (
|
||||
<Flex column gap={6}>
|
||||
{sources.map((source) => (
|
||||
<SourceItem
|
||||
key={source.id}
|
||||
source={source}
|
||||
selected={source === selectedSource}
|
||||
onSelect={selectSource}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</ListContainer>
|
||||
<Footer justify="space-between" align="center" gap={8}>
|
||||
<StyledText type="secondary">
|
||||
{selectedSource ? (
|
||||
<Trans
|
||||
defaults="Create issue in <em>{{ location }}</em>"
|
||||
values={{
|
||||
location: `${selectedSource.owner.name}/${selectedSource.name} `,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t("Select a source to create issue")
|
||||
)}
|
||||
</StyledText>
|
||||
<Button
|
||||
disabled={!selectedSource || isCreating}
|
||||
onClick={handleCreateIssue}
|
||||
>
|
||||
{isCreating ? `${t("Creating")}…` : t("Create")}
|
||||
</Button>
|
||||
</Footer>
|
||||
</FlexContainer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const SourceItem = ({
|
||||
source,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
source: IssueSource;
|
||||
selected: boolean;
|
||||
onSelect: (source: IssueSource) => void;
|
||||
}) => (
|
||||
<SourceItemWrapper
|
||||
justify="space-between"
|
||||
onClick={() => onSelect(source)}
|
||||
$selected={selected}
|
||||
>
|
||||
<Text>{source.name}</Text>
|
||||
<Flex align="center" gap={2}>
|
||||
<PluginIcon id={source.service} size={20} />
|
||||
<SourceAccount type="tertiary" size="xsmall">
|
||||
{source.owner.name}
|
||||
</SourceAccount>
|
||||
</Flex>
|
||||
</SourceItemWrapper>
|
||||
);
|
||||
|
||||
export const FlexContainer = styled(Flex)`
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: -24px;
|
||||
outline: none;
|
||||
`;
|
||||
|
||||
const ListContainer = styled.div`
|
||||
height: 65vh;
|
||||
padding: 0 24px 12px;
|
||||
overflow: scroll;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
height: 40vh;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SourceAccount = styled(Text)``;
|
||||
|
||||
const SourceItemWrapper = styled(Flex)<{ $selected: boolean }>`
|
||||
font-size: 16px;
|
||||
cursor: var(--pointer);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
${(props) =>
|
||||
props.$selected &&
|
||||
`
|
||||
background: ${props.theme.accent};
|
||||
color: ${props.theme.white};
|
||||
|
||||
${SourceAccount} {
|
||||
color: ${props.theme.white};
|
||||
}
|
||||
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 6px;
|
||||
font-size: 15px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled(Flex)`
|
||||
height: 64px;
|
||||
border-top: 1px solid ${(props) => props.theme.horizontalRule};
|
||||
padding: 0 24px;
|
||||
`;
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
${ellipsis()}
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
@@ -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();
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { useRecentIssueSources } from "~/editor/hooks/useRecentIssueSources";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
@@ -106,6 +107,7 @@ export default function SelectionToolbar(props: Props) {
|
||||
const isActive = useIsActive(view.state) || isMobile;
|
||||
const isDragging = useIsDragging();
|
||||
const previousIsActive = usePrevious(isActive);
|
||||
const { issueSources: recentIssueSources } = useRecentIssueSources();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Trigger callbacks when the toolbar is opened or closed
|
||||
@@ -213,7 +215,13 @@ export default function SelectionToolbar(props: Props) {
|
||||
} else if (isNoticeSelection && selection.empty) {
|
||||
items = getNoticeMenuItems(state, readOnly, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, isMobile, dictionary);
|
||||
items = getFormattingMenuItems(
|
||||
state,
|
||||
isTemplate,
|
||||
isMobile,
|
||||
recentIssueSources,
|
||||
dictionary
|
||||
);
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
MentionPlaceholder,
|
||||
MentionType,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "@shared/types";
|
||||
import { t } from "i18next";
|
||||
import { action, observable } from "mobx";
|
||||
import {
|
||||
Command,
|
||||
EditorState,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
} from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Primitive } from "utility-types";
|
||||
import { v4 } from "uuid";
|
||||
import stores from "~/stores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { CreateIssueDialog } from "../components/CreateIssueDialog";
|
||||
import { addRecentIssueSource } from "../hooks/useRecentIssueSources";
|
||||
|
||||
export default class CreateIssue extends Extension {
|
||||
private state: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
} = observable({
|
||||
open: false,
|
||||
title: "",
|
||||
});
|
||||
|
||||
private key = new PluginKey(this.name);
|
||||
|
||||
get name() {
|
||||
return "issue";
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: this.key,
|
||||
state: {
|
||||
init: () => DecorationSet.empty,
|
||||
apply: (tr, set) => {
|
||||
// See if the transaction adds, replaces, or removes any placeholders.
|
||||
const meta = tr.getMeta(this.key);
|
||||
|
||||
// We only want a single paste placeholder at a time, so if we're adding a new
|
||||
// placeholder we can just return a new DecorationSet and avoid mapping logic.
|
||||
if (meta?.add) {
|
||||
const { from, to, id } = meta.add;
|
||||
const decorations = [
|
||||
Decoration.inline(
|
||||
from,
|
||||
to,
|
||||
{},
|
||||
{
|
||||
id,
|
||||
}
|
||||
),
|
||||
];
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
let mapping = tr.mapping;
|
||||
const hasDecorations = set.find().length;
|
||||
|
||||
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
|
||||
try {
|
||||
mapping = recreateTransform(tr.before, tr.doc, {
|
||||
complexSteps: true,
|
||||
wordDiffs: false,
|
||||
simplifyDiff: true,
|
||||
}).mapping;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to recreate transform: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
set = set.map(mapping, tr.doc);
|
||||
|
||||
if (meta?.replace) {
|
||||
const { id } = meta.replace;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return DecorationSet.create(tr.doc, decorations);
|
||||
}
|
||||
|
||||
if (meta?.remove) {
|
||||
const { id } = meta.remove;
|
||||
const decorations = set.find(
|
||||
undefined,
|
||||
undefined,
|
||||
(spec) => spec.id === id
|
||||
);
|
||||
return set.remove(decorations);
|
||||
}
|
||||
|
||||
return set;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
return {
|
||||
"Mod-Alt-i": (state, dispatch) => {
|
||||
const isCode = isInCode(state);
|
||||
const isEmpty = state.selection.empty;
|
||||
|
||||
if (isCode || isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
const { from } = state.selection;
|
||||
const to = from + title.length;
|
||||
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.near(state.doc.resolve(from)))
|
||||
.setMeta(this.key, { add: { from, to, id: title } });
|
||||
dispatch?.(tr);
|
||||
|
||||
this.openDialog(title);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
commands() {
|
||||
return (attrs: Record<string, Primitive>): Command =>
|
||||
action((state, dispatch) => {
|
||||
const title = attrs.title as string;
|
||||
const source = attrs.source
|
||||
? (JSON.parse(attrs.source as string) as IssueSource)
|
||||
: undefined;
|
||||
|
||||
this.state.title = title;
|
||||
|
||||
const { from } = state.selection;
|
||||
const to = from + title.length;
|
||||
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.near(state.doc.resolve(to)))
|
||||
.setMeta(this.key, { add: { from, to, id: title } });
|
||||
|
||||
dispatch?.(tr);
|
||||
|
||||
if (source) {
|
||||
tr.replaceWith(
|
||||
from,
|
||||
to,
|
||||
state.schema.nodes.mention.create({
|
||||
id: v4(),
|
||||
type: MentionPlaceholder,
|
||||
label: title,
|
||||
href: title,
|
||||
modelId: v4(),
|
||||
actorId: stores.auth.currentUserId,
|
||||
})
|
||||
).setMeta(this.key, { replace: { id: title } });
|
||||
}
|
||||
|
||||
dispatch?.(tr);
|
||||
|
||||
if (source) {
|
||||
void this.createIssue(source);
|
||||
} else {
|
||||
this.openDialog(title);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
widget = () => (
|
||||
<CreateIssueDialog
|
||||
issueTitle={this.state.title}
|
||||
open={this.state.open}
|
||||
onCreate={this.createIssue}
|
||||
onClose={this.closeDialog}
|
||||
/>
|
||||
);
|
||||
|
||||
private createIssue = async (source: IssueSource) => {
|
||||
try {
|
||||
addRecentIssueSource(source);
|
||||
const res = await client.post("/issues.create", {
|
||||
title: this.state.title,
|
||||
source,
|
||||
});
|
||||
this.addMentionNode(res.data);
|
||||
toast.success(t("Issue created"));
|
||||
} catch (err) {
|
||||
this.removeDecorations(source);
|
||||
toast.error(t("Couldn’t create the issue, try again?"));
|
||||
}
|
||||
};
|
||||
|
||||
private addMentionNode = (
|
||||
issue: UnfurlResponse[UnfurlResourceType.Issue]
|
||||
) => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
|
||||
const result = this.findPlaceholder(state, this.state.title);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr.deleteRange(result[0], result[1]);
|
||||
view.dispatch(
|
||||
tr
|
||||
.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
|
||||
.setMeta(this.key, {
|
||||
remove: { id: this.state.title },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.editor.commands.mention({
|
||||
id: v4(),
|
||||
type: MentionType.Issue,
|
||||
label: this.state.title,
|
||||
href: issue.url,
|
||||
modelId: v4(),
|
||||
actorId: stores.auth.currentUserId,
|
||||
});
|
||||
};
|
||||
|
||||
private removeDecorations = action((source: IssueSource) => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
|
||||
const tr = state.tr.setMeta(this.key, {
|
||||
remove: { id: this.state.title },
|
||||
});
|
||||
|
||||
const result = this.findPlaceholder(state, this.state.title);
|
||||
|
||||
// Placeholder node would have been inserted in recent issue menu flow only.
|
||||
// We want to reset it with the selected text.
|
||||
if (source && result) {
|
||||
tr.replaceWith(
|
||||
result[0],
|
||||
result[1],
|
||||
state.schema.nodeFromJSON({ type: "text", text: this.state.title })
|
||||
);
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
|
||||
this.state.title = "";
|
||||
});
|
||||
|
||||
private openDialog = action((title: string) => {
|
||||
this.state.title = title;
|
||||
this.state.open = true;
|
||||
});
|
||||
|
||||
private closeDialog = action(() => {
|
||||
const { view } = this.editor;
|
||||
const { state } = view;
|
||||
|
||||
const result = this.findPlaceholder(state, this.state.title);
|
||||
|
||||
if (result) {
|
||||
const tr = state.tr
|
||||
.setSelection(TextSelection.near(state.doc.resolve(result[0])))
|
||||
.setMeta(this.key, {
|
||||
remove: { id: this.state.title },
|
||||
});
|
||||
view.dispatch(tr);
|
||||
}
|
||||
|
||||
this.state.title = "";
|
||||
this.state.open = false;
|
||||
});
|
||||
|
||||
private findPlaceholder = (
|
||||
state: EditorState,
|
||||
id: string
|
||||
): [number, number] | null => {
|
||||
const decos = this.key.getState(state) as DecorationSet;
|
||||
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
|
||||
return found?.length ? [found[0].from, found[0].to] : null;
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import BlockMenuExtension from "~/editor/extensions/BlockMenu";
|
||||
import ClipboardTextSerializer from "~/editor/extensions/ClipboardTextSerializer";
|
||||
import CreateIssueExtension from "~/editor/extensions/CreateIssue";
|
||||
import EmojiMenuExtension from "~/editor/extensions/EmojiMenu";
|
||||
import FindAndReplaceExtension from "~/editor/extensions/FindAndReplace";
|
||||
import HoverPreviewsExtension from "~/editor/extensions/HoverPreviews";
|
||||
@@ -24,6 +25,7 @@ export const withUIExtensions = (nodes: Nodes) => [
|
||||
MentionMenuExtension,
|
||||
FindAndReplaceExtension,
|
||||
HoverPreviewsExtension,
|
||||
CreateIssueExtension,
|
||||
// Order these default key handlers last
|
||||
PreventTab,
|
||||
Keys,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import React from "react";
|
||||
import usePersistedState, {
|
||||
setPersistedState,
|
||||
} from "~/hooks/usePersistedState";
|
||||
|
||||
const StorageKey = "recent-issue-sources";
|
||||
const MaxCount = 5;
|
||||
|
||||
export function useRecentIssueSources() {
|
||||
const [issueSources, setIssueSources] = usePersistedState<IssueSource[]>(
|
||||
StorageKey,
|
||||
[] as IssueSource[]
|
||||
);
|
||||
|
||||
const addIssueSource = React.useCallback(
|
||||
(source: IssueSource) => {
|
||||
const newIssueSources = insertAndTrim(issueSources, source);
|
||||
setIssueSources(newIssueSources);
|
||||
},
|
||||
[issueSources, setIssueSources]
|
||||
);
|
||||
|
||||
return { issueSources, addIssueSource };
|
||||
}
|
||||
|
||||
export function addRecentIssueSource(source: IssueSource) {
|
||||
const issueSources: IssueSource[] = Storage.get(StorageKey) ?? [];
|
||||
const newIssueSources = insertAndTrim(issueSources, source);
|
||||
setPersistedState(StorageKey, newIssueSources);
|
||||
}
|
||||
|
||||
function insertAndTrim(issueSources: IssueSource[], source: IssueSource) {
|
||||
const newIssueSources = issueSources.filter((s) => s.id !== source.id);
|
||||
newIssueSources.unshift(source);
|
||||
|
||||
if (newIssueSources.length > MaxCount) {
|
||||
newIssueSources.pop();
|
||||
}
|
||||
|
||||
return newIssueSources;
|
||||
}
|
||||
|
||||
export type RecentIssueSourcesResponse = ReturnType<
|
||||
typeof useRecentIssueSources
|
||||
>;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
BoldIcon,
|
||||
CodeIcon,
|
||||
@@ -17,8 +18,9 @@ import {
|
||||
IndentIcon,
|
||||
CopyIcon,
|
||||
Heading3Icon,
|
||||
PlusIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { EditorState, TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Highlight from "@shared/editor/marks/Highlight";
|
||||
@@ -28,14 +30,17 @@ import { isInList } from "@shared/editor/queries/isInList";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
|
||||
export default function formattingMenuItems(
|
||||
state: EditorState,
|
||||
isTemplate: boolean,
|
||||
isMobile: boolean,
|
||||
recentIssueSources: IssueSource[],
|
||||
dictionary: Dictionary
|
||||
): MenuItem[] {
|
||||
const { schema } = state;
|
||||
@@ -43,12 +48,39 @@ export default function formattingMenuItems(
|
||||
const isCodeBlock = isInCode(state, { onlyBlock: true });
|
||||
const isEmpty = state.selection.empty;
|
||||
|
||||
const selectedText =
|
||||
!isEmpty && state.selection instanceof TextSelection
|
||||
? state.doc.cut(state.selection.from, state.selection.to).textContent
|
||||
: undefined;
|
||||
|
||||
const highlight = getMarksBetween(
|
||||
state.selection.from,
|
||||
state.selection.to,
|
||||
state
|
||||
).find(({ mark }) => mark.type.name === "highlight");
|
||||
|
||||
const issueSourcesChildren = recentIssueSources.length
|
||||
? recentIssueSources.map<MenuItem>((source) => ({
|
||||
name: "issue",
|
||||
label: `${source.owner.name}/${source.name}`,
|
||||
icon: <PluginIcon id={source.service} />,
|
||||
attrs: {
|
||||
title: selectedText,
|
||||
source: JSON.stringify(source),
|
||||
},
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
if (issueSourcesChildren) {
|
||||
issueSourcesChildren.push({
|
||||
name: "issue",
|
||||
label: `${t("Other")}…`,
|
||||
attrs: {
|
||||
title: selectedText,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: "placeholder",
|
||||
@@ -259,6 +291,21 @@ export default function formattingMenuItems(
|
||||
shortcut: `${metaDisplay}+C`,
|
||||
visible: isCode && !isCodeBlock && (!isMobile || !isEmpty),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCode && !isEmpty,
|
||||
},
|
||||
{
|
||||
name: "issue",
|
||||
tooltip: dictionary.createIssue,
|
||||
shortcut: `${metaDisplay}+⌥+I`,
|
||||
icon: <PlusIcon />,
|
||||
attrs: {
|
||||
title: selectedText,
|
||||
},
|
||||
visible: !isCode && !!selectedText,
|
||||
children: issueSourcesChildren,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function useDictionary() {
|
||||
codeInline: t("Code"),
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createIssue: t("Create issue from selection"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,19 @@
|
||||
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 { 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,41 +24,38 @@ 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 = 150,
|
||||
children,
|
||||
}) => {
|
||||
const { ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
submitText || t("Crop image");
|
||||
|
||||
@observable
|
||||
isCropping = false;
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
@observable
|
||||
zoom = 1;
|
||||
const avatarEditorRef = useRef<AvatarEditor>(null);
|
||||
|
||||
@observable
|
||||
file: File;
|
||||
|
||||
avatarEditorRef = React.createRef<AvatarEditor>();
|
||||
|
||||
static defaultProps = {
|
||||
submitText: "Crop Image",
|
||||
borderRadius: 150,
|
||||
const onDropAccepted = async (files: File[]) => {
|
||||
setIsCropping(true);
|
||||
setFile(files[0]);
|
||||
};
|
||||
|
||||
onDropAccepted = async (files: File[]) => {
|
||||
this.isCropping = true;
|
||||
this.file = files[0];
|
||||
};
|
||||
|
||||
handleCrop = () => {
|
||||
this.isUploading = true;
|
||||
const handleCrop = () => {
|
||||
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(this.uploadImage, 0);
|
||||
setTimeout(uploadImage, 0);
|
||||
};
|
||||
|
||||
uploadImage = async () => {
|
||||
const canvas = this.avatarEditorRef.current?.getImage();
|
||||
const uploadImage = async () => {
|
||||
const canvas = avatarEditorRef.current?.getImage();
|
||||
invariant(canvas, "canvas is not defined");
|
||||
const imageBlob = dataUrlToBlob(canvas.toDataURL());
|
||||
|
||||
@@ -68,88 +65,87 @@ class ImageUpload extends React.Component<RootStore & Props> {
|
||||
maxWidth: 512,
|
||||
});
|
||||
const attachment = await uploadFile(compressed, {
|
||||
name: this.file.name,
|
||||
name: file!.name,
|
||||
preset: AttachmentPreset.Avatar,
|
||||
});
|
||||
void this.props.onSuccess(attachment.url);
|
||||
void onSuccess(attachment.url);
|
||||
} catch (err) {
|
||||
this.props.onError(err.message);
|
||||
onError(err.message);
|
||||
} finally {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
this.isUploading = false;
|
||||
this.isCropping = false;
|
||||
const handleClose = () => {
|
||||
setIsUploading(false);
|
||||
setIsCropping(false);
|
||||
};
|
||||
|
||||
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
this.zoom = parseFloat(target.value);
|
||||
setZoom(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 renderCropping = () => (
|
||||
<Modal
|
||||
onRequestClose={handleClose}
|
||||
fullscreen={false}
|
||||
title={<> </>}
|
||||
isOpen
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
|
||||
{this.isUploading ? "Uploading…" : submitText}
|
||||
</CropButton>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
</AvatarEditorContainer>
|
||||
<RangeInput
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
onChange={handleZoom}
|
||||
/>
|
||||
<br />
|
||||
<ButtonLarge fullwidth onClick={handleCrop} disabled={isUploading}>
|
||||
{isUploading ? `${t(`Uploading`)}…` : submitText}
|
||||
</ButtonLarge>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
if (isCropping && file) {
|
||||
return renderCropping();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.isCropping) {
|
||||
return this.renderCropping();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{this.props.children}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dropzone
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
const AvatarEditorContainer = styled(Flex)`
|
||||
margin-bottom: 30px;
|
||||
@@ -180,8 +176,4 @@ const RangeInput = styled.input`
|
||||
}
|
||||
`;
|
||||
|
||||
const CropButton = styled(Button)`
|
||||
width: 300px;
|
||||
`;
|
||||
|
||||
export default withStores(ImageUpload);
|
||||
export default observer(ImageUpload);
|
||||
|
||||
@@ -5,12 +5,12 @@ 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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
+13
-13
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.777.0",
|
||||
"@aws-sdk/lib-storage": "3.777.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.777.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.777.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.775.0",
|
||||
"@aws-sdk/client-s3": "3.782.0",
|
||||
"@aws-sdk/lib-storage": "3.782.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.782.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.782.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.782.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
@@ -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,7 +183,7 @@
|
||||
"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.2",
|
||||
"prosemirror-model": "^1.25.0",
|
||||
@@ -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.16",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-pwa": "^0.20.3",
|
||||
"winston": "^3.17.0",
|
||||
"ws": "^7.5.10",
|
||||
@@ -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",
|
||||
@@ -377,5 +377,5 @@
|
||||
"rollup": "^4.5.1",
|
||||
"prismjs": "1.30.0"
|
||||
},
|
||||
"version": "0.82.0"
|
||||
}
|
||||
"version": "0.83.0"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,4 +13,9 @@ PluginManager.add([
|
||||
component: React.lazy(() => import("./Settings")),
|
||||
},
|
||||
},
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Endpoints } from "@octokit/types";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Integration, User } from "@server/models";
|
||||
import { CreateIssueResponse } from "@server/types";
|
||||
import { BaseIssueProvider } from "@server/utils/IssueProvider";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
IntegrationService,
|
||||
IntegrationType,
|
||||
UnfurlResourceType,
|
||||
} from "@shared/types";
|
||||
import { GitHub } from "./github";
|
||||
|
||||
// This is needed to account for Octokit paginate response type mismatch.
|
||||
type ReposForInstallation =
|
||||
Endpoints["GET /installation/repositories"]["response"]["data"]["repositories"];
|
||||
|
||||
export class GitHubIssueProvider extends BaseIssueProvider {
|
||||
constructor() {
|
||||
super(IntegrationService.GitHub);
|
||||
}
|
||||
|
||||
async fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]> {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const repos =
|
||||
(await client.requestRepos()) as unknown as ReposForInstallation;
|
||||
|
||||
const sources = repos.map<IssueSource>((repo) => ({
|
||||
id: String(repo.id),
|
||||
name: repo.name,
|
||||
owner: { id: String(repo.owner.id), name: repo.owner.login },
|
||||
service: IntegrationService.GitHub,
|
||||
}));
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
async createIssue(
|
||||
title: string,
|
||||
source: IssueSource,
|
||||
actor: User
|
||||
): Promise<CreateIssueResponse | undefined> {
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
service: IntegrationService.GitHub,
|
||||
teamId: actor.teamId,
|
||||
"settings.github.installation.account.name": source.owner.name,
|
||||
},
|
||||
})) as Integration<IntegrationType.Embed> | undefined;
|
||||
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await GitHub.authenticateAsInstallation(
|
||||
integration.settings.github!.installation.id
|
||||
);
|
||||
|
||||
const { data } = await client.createIssue({
|
||||
owner: source.owner.name,
|
||||
repo: source.name,
|
||||
title,
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
type: UnfurlResourceType.Issue,
|
||||
cacheKey: data.html_url,
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.warn("Failed to create issue in GitHub", err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Router from "koa-router";
|
||||
import find from "lodash/find";
|
||||
import { IntegrationService, IntegrationType } from "@shared/types";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { createContext } from "@server/context";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
@@ -88,30 +89,27 @@ router.get(
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await Integration.create(
|
||||
{
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
await Integration.createWithCtx(createContext({ user, transaction }), {
|
||||
service: IntegrationService.GitHub,
|
||||
type: IntegrationType.Embed,
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
authenticationId: authentication.id,
|
||||
settings: {
|
||||
github: {
|
||||
installation: {
|
||||
id: installationId!,
|
||||
account: {
|
||||
id: installation.account?.id,
|
||||
name:
|
||||
// @ts-expect-error Property 'login' does not exist on type
|
||||
installation.account?.login,
|
||||
avatarUrl: installation.account?.avatar_url,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
ctx.redirect(GitHubUtils.url);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -40,6 +40,32 @@ const requestPlugin = (octokit: Octokit) => ({
|
||||
},
|
||||
}),
|
||||
|
||||
requestRepos: () =>
|
||||
octokit.paginate(octokit.rest.apps.listReposAccessibleToInstallation, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}),
|
||||
|
||||
createIssue: async ({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
}: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
title: string;
|
||||
}) =>
|
||||
octokit.request(`POST /repos/{owner}/{repo}/issues`, {
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
headers: {
|
||||
Accept: "application/vnd.github.text+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Fetches app installations accessible to the user
|
||||
*
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { IntegrationService } from "@shared/types";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import { PluginManager, Hook } from "@server/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import { GitHubIssueProvider } from "./GitHubIssueProvider";
|
||||
import router from "./api/github";
|
||||
import env from "./env";
|
||||
import { GitHub } from "./github";
|
||||
@@ -24,6 +26,10 @@ if (enabled) {
|
||||
type: Hook.UnfurlProvider,
|
||||
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
|
||||
},
|
||||
{
|
||||
type: Hook.IssueProvider,
|
||||
value: new GitHubIssueProvider(),
|
||||
},
|
||||
{
|
||||
type: Hook.Uninstall,
|
||||
value: uninstall,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -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,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
queryInterface.addColumn("integrations", "issueSources", {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
queryInterface.removeColumn("integrations", "issueSources");
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
IsIn,
|
||||
AfterDestroy,
|
||||
} from "sequelize-typescript";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { IntegrationType, IntegrationService } from "@shared/types";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import Collection from "@server/models/Collection";
|
||||
@@ -53,6 +54,9 @@ class Integration<T = unknown> extends ParanoidModel<
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
events: string[];
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
issueSources: IssueSource[] | null;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "userId")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Integration } from "@server/models";
|
||||
import BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import { IntegrationEvent, Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import CacheIssueSourcesTask from "../tasks/CacheIssueSourcesTask";
|
||||
|
||||
export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
static applicableEvents: Event["name"][] = ["integrations.create"];
|
||||
@@ -18,6 +19,11 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the available issue sources in the integration record.
|
||||
await CacheIssueSourcesTask.schedule({
|
||||
integrationId: integration.id,
|
||||
});
|
||||
|
||||
// Clear the cache of unfurled data for the team as it may be stale now.
|
||||
await CacheHelper.clearData(CacheHelper.getUnfurlKey(integration.teamId));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Integration } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import BaseTask from "./BaseTask";
|
||||
|
||||
const plugins = PluginManager.getHooks(Hook.IssueProvider);
|
||||
|
||||
type Props = {
|
||||
integrationId: string;
|
||||
};
|
||||
|
||||
export default class CacheIssueSourcesTask extends BaseTask<Props> {
|
||||
async perform({ integrationId }: Props) {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const integration = await Integration.findByPk(integrationId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = plugins.find(
|
||||
(p) => p.value.service === integration.service
|
||||
);
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = await plugin.value.fetchSources(integration);
|
||||
integration.issueSources = sources;
|
||||
await integration.save({ transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import groups from "./groups";
|
||||
import imports from "./imports";
|
||||
import installation from "./installation";
|
||||
import integrations from "./integrations";
|
||||
import issues from "./issues";
|
||||
import apiErrorHandler from "./middlewares/apiErrorHandler";
|
||||
import apiResponse from "./middlewares/apiResponse";
|
||||
import apiTracer from "./middlewares/apiTracer";
|
||||
@@ -99,6 +100,7 @@ router.use("/", urls.routes());
|
||||
router.use("/", userMemberships.routes());
|
||||
router.use("/", reactions.routes());
|
||||
router.use("/", imports.routes());
|
||||
router.use("/", issues.routes());
|
||||
|
||||
if (!env.isCloudHosted) {
|
||||
router.use("/", installation.routes());
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./issues";
|
||||
@@ -0,0 +1,70 @@
|
||||
import { InternalError, InvalidRequestError } from "@server/errors";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Integration } from "@server/models";
|
||||
import presentUnfurl from "@server/presenters/unfurl";
|
||||
import { APIContext } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { UserRole } from "@shared/types";
|
||||
import Router from "koa-router";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
const plugins = PluginManager.getHooks(Hook.IssueProvider);
|
||||
|
||||
router.post(
|
||||
"issues.list_sources",
|
||||
auth({ role: UserRole.Member }),
|
||||
async (ctx: APIContext) => {
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const integrations = await Integration.findAll({
|
||||
attributes: ["issueSources"],
|
||||
where: { teamId: user.teamId },
|
||||
});
|
||||
|
||||
const sources = integrations
|
||||
.flatMap((integration) => integration.issueSources)
|
||||
.filter(Boolean) as IssueSource[];
|
||||
|
||||
ctx.body = {
|
||||
data: sources,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"issues.create",
|
||||
auth({ role: UserRole.Member }),
|
||||
validate(T.IssuesCreateSchema),
|
||||
async (ctx: APIContext<T.IssuesCreateReq>) => {
|
||||
const { title, source } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const plugin = plugins.find((p) => p.value.service === source.service);
|
||||
|
||||
if (!plugin) {
|
||||
throw InvalidRequestError();
|
||||
}
|
||||
|
||||
const issue = await plugin.value.createIssue(title, source, user);
|
||||
|
||||
if (!issue) {
|
||||
throw InternalError();
|
||||
}
|
||||
|
||||
await CacheHelper.setData(
|
||||
CacheHelper.getUnfurlKey(user.teamId, issue.cacheKey),
|
||||
issue,
|
||||
plugin.value.cacheExpiry
|
||||
);
|
||||
|
||||
ctx.body = {
|
||||
data: await presentUnfurl(issue),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import { z } from "zod";
|
||||
import { BaseSchema } from "../schema";
|
||||
|
||||
export const IssuesCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
title: z.string().nonempty(),
|
||||
source: IssueSource,
|
||||
}),
|
||||
});
|
||||
|
||||
export type IssuesCreateReq = z.infer<typeof IssuesCreateSchema>;
|
||||
@@ -2,6 +2,7 @@ import { ParameterizedContext, DefaultContext } from "koa";
|
||||
import { IRouterParamContext } from "koa-router";
|
||||
import { InferAttributes, Model, Transaction } from "sequelize";
|
||||
import { z } from "zod";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
CollectionSort,
|
||||
NavigationNode,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
ProsemirrorData,
|
||||
IntegrationType,
|
||||
} from "@shared/types";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
import { AccountProvisionerResult } from "./commands/accountProvisioner";
|
||||
@@ -585,6 +587,19 @@ export type UnfurlSignature = (
|
||||
|
||||
export type UninstallSignature = (integration: Integration) => Promise<void>;
|
||||
|
||||
export type CreateIssueResponse = Unfurl & { cacheKey: string };
|
||||
|
||||
export type IssueProvider = {
|
||||
listSources: (
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
) => Promise<IssueSource[]>;
|
||||
createIssue: (
|
||||
title: string,
|
||||
source: IssueSource,
|
||||
actor: User
|
||||
) => Promise<CreateIssueResponse | undefined>;
|
||||
};
|
||||
|
||||
export type Replace<T, K extends keyof T, N extends string> = {
|
||||
[P in keyof T as P extends K ? N : P]: T[P extends K ? K : P];
|
||||
};
|
||||
|
||||
@@ -18,11 +18,14 @@ export class CacheHelper {
|
||||
* @param key Cache key
|
||||
* @param callback Callback to get the data if not found in cache
|
||||
* @param expiry Cache data expiry in seconds
|
||||
* @param lockTimeout Lock timeout in milliseconds
|
||||
* @returns The data from cache or the result of the callback
|
||||
*/
|
||||
public static async getDataOrSet<T>(
|
||||
key: string,
|
||||
callback: () => Promise<T | undefined>,
|
||||
expiry?: number
|
||||
expiry: number,
|
||||
lockTimeout: number = MutexLock.defaultLockTimeout
|
||||
): Promise<T | undefined> {
|
||||
let cache = await this.getData<T>(key);
|
||||
|
||||
@@ -35,10 +38,7 @@ export class CacheHelper {
|
||||
const lockKey = `lock:${key}`;
|
||||
try {
|
||||
try {
|
||||
lock = await MutexLock.lock.acquire(
|
||||
[lockKey],
|
||||
MutexLock.defaultLockTimeout
|
||||
);
|
||||
lock = await MutexLock.lock.acquire([lockKey], lockTimeout);
|
||||
} catch (err) {
|
||||
Logger.error(`Could not acquire lock for ${key}`, err);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Integration, User } from "@server/models";
|
||||
import { CreateIssueResponse } from "@server/types";
|
||||
import { IssueSource } from "@shared/schema";
|
||||
import {
|
||||
IntegrationType,
|
||||
IssueProviderIntegrationService,
|
||||
} from "@shared/types";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
|
||||
export abstract class BaseIssueProvider {
|
||||
service: IssueProviderIntegrationService;
|
||||
cacheExpiry: number;
|
||||
|
||||
constructor(service: IssueProviderIntegrationService, cacheExpiry?: number) {
|
||||
this.service = service;
|
||||
this.cacheExpiry = cacheExpiry ?? Minute.seconds;
|
||||
}
|
||||
|
||||
abstract fetchSources(
|
||||
integration: Integration<IntegrationType.Embed>
|
||||
): Promise<IssueSource[]>;
|
||||
|
||||
abstract createIssue(
|
||||
title: string,
|
||||
source: IssueSource,
|
||||
actor: User
|
||||
): Promise<CreateIssueResponse | undefined>;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import Logger from "@server/logging/Logger";
|
||||
import type BaseProcessor from "@server/queues/processors/BaseProcessor";
|
||||
import type BaseTask from "@server/queues/tasks/BaseTask";
|
||||
import { UnfurlSignature, UninstallSignature } from "@server/types";
|
||||
import { BaseIssueProvider } from "./IssueProvider";
|
||||
|
||||
export enum PluginPriority {
|
||||
VeryHigh = 0,
|
||||
@@ -25,6 +26,7 @@ export enum Hook {
|
||||
API = "api",
|
||||
AuthProvider = "authProvider",
|
||||
EmailTemplate = "emailTemplate",
|
||||
IssueProvider = "issueProvider",
|
||||
Processor = "processor",
|
||||
Task = "task",
|
||||
UnfurlProvider = "unfurl",
|
||||
@@ -39,6 +41,7 @@ type PluginValueMap = {
|
||||
[Hook.API]: Router;
|
||||
[Hook.AuthProvider]: { router: Router; id: string };
|
||||
[Hook.EmailTemplate]: typeof BaseEmail;
|
||||
[Hook.IssueProvider]: BaseIssueProvider;
|
||||
[Hook.Processor]: typeof BaseProcessor;
|
||||
[Hook.Task]: typeof BaseTask<any>;
|
||||
[Hook.Uninstall]: UninstallSignature;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { generateAvatarUrl } from "./avatars";
|
||||
|
||||
it("should return clearbit url if available", async () => {
|
||||
const url = await generateAvatarUrl({
|
||||
id: "google",
|
||||
domain: "google.com",
|
||||
});
|
||||
expect(url).toBe("https://logo.clearbit.com/google.com");
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import crypto from "crypto";
|
||||
import fetch from "./fetch";
|
||||
|
||||
export async function generateAvatarUrl({
|
||||
id,
|
||||
domain,
|
||||
}: {
|
||||
id: string;
|
||||
domain?: string;
|
||||
}) {
|
||||
// attempt to get logo from Clearbit API. If one doesn't exist then
|
||||
// fall back to using tiley to generate a placeholder logo
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(id);
|
||||
let cbResponse, cbUrl;
|
||||
|
||||
if (domain) {
|
||||
cbUrl = `https://logo.clearbit.com/${domain}`;
|
||||
|
||||
try {
|
||||
cbResponse = await fetch(cbUrl);
|
||||
} catch (err) {
|
||||
// okay
|
||||
}
|
||||
}
|
||||
|
||||
return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildStar,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import { collectionIndexing, starIndexing } from "./indexing";
|
||||
|
||||
describe("collectionIndexing", () => {
|
||||
it("should generate index for collections without index", async () => {
|
||||
const team = await buildTeam();
|
||||
const collections = await Promise.all([
|
||||
buildCollection({
|
||||
teamId: team.id,
|
||||
}),
|
||||
buildCollection({
|
||||
teamId: team.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Set index to null to simulate no index
|
||||
collections[0].index = null;
|
||||
collections[1].index = null;
|
||||
await collections[0].save({ hooks: false });
|
||||
await collections[1].save({ hooks: false });
|
||||
|
||||
const result = await collectionIndexing(team.id, {});
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
expect(result[collections[0].id]).toBeTruthy();
|
||||
expect(result[collections[1].id]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should maintain existing indices", async () => {
|
||||
const team = await buildTeam();
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
index: "a1",
|
||||
});
|
||||
|
||||
const result = await collectionIndexing(team.id, {});
|
||||
expect(result[collection.id]).toBe("a1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("starIndexing", () => {
|
||||
it("should generate index for stars without index", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument();
|
||||
const stars = await Promise.all([
|
||||
buildStar({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
buildStar({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Set index to null to simulate no index
|
||||
stars[0].index = null;
|
||||
stars[1].index = null;
|
||||
await stars[0].save({ hooks: false });
|
||||
await stars[1].save({ hooks: false });
|
||||
|
||||
const result = await starIndexing(user.id);
|
||||
expect(Object.keys(result).length).toBe(2);
|
||||
expect(result[stars[0].id]).toBeTruthy();
|
||||
expect(result[stars[1].id]).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should maintain existing indices", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const document = await buildDocument();
|
||||
const star = await buildStar({
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
index: "a1",
|
||||
});
|
||||
|
||||
const result = await starIndexing(user.id);
|
||||
expect(result[star.id]).toBe("a1");
|
||||
});
|
||||
});
|
||||
@@ -10,10 +10,8 @@ export async function collectionIndexing(
|
||||
const collections = await Collection.findAll({
|
||||
where: {
|
||||
teamId,
|
||||
// no point in maintaining index of deleted collections.
|
||||
deletedAt: null,
|
||||
},
|
||||
attributes: ["id", "index", "name"],
|
||||
attributes: ["id", "index", "name", "teamId"],
|
||||
transaction,
|
||||
});
|
||||
|
||||
@@ -26,7 +24,9 @@ export async function collectionIndexing(
|
||||
for (const collection of sortable) {
|
||||
if (collection.index === null) {
|
||||
collection.index = fractionalIndex(previousIndex, null);
|
||||
promises.push(collection.save({ fields: ["index"], transaction })); // save only index to prevent overwriting other unfetched fields.
|
||||
promises.push(
|
||||
collection.save({ fields: ["index"], silent: true, transaction })
|
||||
);
|
||||
}
|
||||
|
||||
previousIndex = collection.index;
|
||||
@@ -67,7 +67,7 @@ export async function starIndexing(userId: string) {
|
||||
for (const star of sortable) {
|
||||
if (star.index === null) {
|
||||
star.index = fractionalIndex(previousIndex, null);
|
||||
promises.push(star.save());
|
||||
promises.push(star.save({ silent: true }));
|
||||
}
|
||||
|
||||
previousIndex = star.index;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import styled, { css } from "styled-components";
|
||||
import { ellipsis } from "../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()}
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
@@ -1,17 +1,49 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
CollectionIcon,
|
||||
WarningIcon,
|
||||
} from "outline-icons";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
|
||||
import { PullRequestIcon } from "../../components/PullRequestIcon";
|
||||
import Spinner from "../../components/Spinner";
|
||||
import Text from "../../components/Text";
|
||||
import useIsMounted from "../../hooks/useIsMounted";
|
||||
import useStores from "../../hooks/useStores";
|
||||
import theme from "../../styles/theme";
|
||||
import type {
|
||||
JSONValue,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import { cn } from "../styles/utils";
|
||||
import { ComponentProps } from "../types";
|
||||
|
||||
const getAttributesFromNode = (node: Node) => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
|
||||
const { class: className, ...attrs } = spec[1];
|
||||
return { className, ...attrs };
|
||||
type Attrs = {
|
||||
className: string;
|
||||
unfurl?: UnfurlResponse[keyof UnfurlResponse];
|
||||
} & Record<string, JSONValue>;
|
||||
|
||||
const getAttributesFromNode = (node: Node): Attrs => {
|
||||
const spec = node.type.spec.toDOM?.(node) as any as Record<
|
||||
string,
|
||||
JSONValue
|
||||
>[];
|
||||
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
|
||||
|
||||
return {
|
||||
className: className as Attrs["className"],
|
||||
unfurl: unfurl ? (JSON.parse(unfurl as any) as Attrs["unfurl"]) : undefined,
|
||||
...attrs,
|
||||
};
|
||||
};
|
||||
|
||||
export const MentionUser = observer(function MentionUser_(
|
||||
@@ -20,7 +52,7 @@ export const MentionUser = observer(function MentionUser_(
|
||||
const { isSelected, node } = props;
|
||||
const { users } = useStores();
|
||||
const user = users.get(node.attrs.modelId);
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -42,7 +74,7 @@ export const MentionDocument = observer(function MentionDocument_(
|
||||
const { documents } = useStores();
|
||||
const doc = documents.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -75,7 +107,7 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
const { collections } = useStores();
|
||||
const collection = collections.get(node.attrs.modelId);
|
||||
const modelId = node.attrs.modelId;
|
||||
const { className, ...attrs } = getAttributesFromNode(node);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (modelId) {
|
||||
@@ -100,3 +132,185 @@ export const MentionCollection = observer(function MentionCollection_(
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
type IssuePrProps = ComponentProps & {
|
||||
onChangeUnfurl: (
|
||||
unfurl:
|
||||
| UnfurlResponse[UnfurlResourceType.Issue]
|
||||
| UnfurlResponse[UnfurlResourceType.PR]
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const MentionIssue = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchIssue = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchIssue();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading className={className} />
|
||||
) : (
|
||||
<MentionError className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<IssueStatusIcon
|
||||
size={14}
|
||||
status={issue.state.name}
|
||||
color={issue.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{issue.title}</Text>
|
||||
<Text type="tertiary">{issue.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
const { unfurls } = useStores();
|
||||
const isMounted = useIsMounted();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
|
||||
|
||||
const { isSelected, node } = props;
|
||||
const {
|
||||
className,
|
||||
unfurl: unfurlAttr,
|
||||
...attrs
|
||||
} = getAttributesFromNode(node);
|
||||
|
||||
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchPR = async () => {
|
||||
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unfurlModel) {
|
||||
onChangeUnfurl({
|
||||
...unfurlModel.data,
|
||||
description: null,
|
||||
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
|
||||
}
|
||||
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
void fetchPR();
|
||||
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
|
||||
|
||||
const sharedProps = {
|
||||
className: cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
}),
|
||||
};
|
||||
|
||||
if (!unfurl) {
|
||||
return !loaded ? (
|
||||
<MentionLoading {...sharedProps} />
|
||||
) : (
|
||||
<MentionError {...sharedProps} />
|
||||
);
|
||||
}
|
||||
|
||||
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
|
||||
|
||||
return (
|
||||
<a
|
||||
{...attrs}
|
||||
{...sharedProps}
|
||||
href={attrs.href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
<Flex align="center" gap={6}>
|
||||
<PullRequestIcon
|
||||
size={14}
|
||||
status={pullRequest.state.name}
|
||||
color={pullRequest.state.color}
|
||||
/>
|
||||
<Flex align="center" gap={4}>
|
||||
<Text>{pullRequest.title}</Text>
|
||||
<Text type="tertiary">{pullRequest.id}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionPlaceholder = () => <MentionLoading className="mention" />;
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<Spinner />
|
||||
<Text type="tertiary">{`${t("Loading")}…`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const MentionError = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<StyledWarningIcon size={20} color={theme.danger} />
|
||||
<Text type="secondary">{`${t("Error loading data")}`}</Text>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -1335,6 +1335,13 @@ mark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block[data-language=none],
|
||||
.code-block[data-language=markdown] {
|
||||
pre code {
|
||||
color: ${props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.code-block[data-language=mermaidjs] {
|
||||
margin: 0.75em 0;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BrowserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Primitive } from "utility-types";
|
||||
@@ -665,7 +666,7 @@ const embeds: EmbedDescriptor[] = [
|
||||
title: "Embed",
|
||||
keywords: "iframe webpage",
|
||||
placeholder: "Paste a URL to embed",
|
||||
icon: <Img src="/images/embed.png" alt="Embed" />,
|
||||
icon: <BrowserIcon />,
|
||||
defaultHidden: false,
|
||||
matchOnInput: false,
|
||||
regexMatch: [new RegExp("^https?://(.*)$")],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
|
||||
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import refractor from "refractor/core";
|
||||
import { getPrismLangForLanguage } from "../lib/code";
|
||||
import { getRefractorLangForLanguage } from "../lib/code";
|
||||
import { isRemoteTransaction } from "../lib/multiplayer";
|
||||
import { findBlockNodes } from "../queries/findChildren";
|
||||
|
||||
@@ -14,6 +14,34 @@ type ParsedNode = {
|
||||
};
|
||||
|
||||
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
|
||||
const languagesToImport = new Set<string>();
|
||||
|
||||
async function loadLanguage(language: string) {
|
||||
if (!language || refractor.registered(language)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// @ts-expect-error we are adding a module to the window object to work
|
||||
// around the fact that refractor doesn't export ESM but import expects it.
|
||||
// See the rules of dynamic imports:
|
||||
// https://github.com/rollup/plugins/blob/e1a5ef99f1578eb38a8c87563cb9651db228f3bd/packages/dynamic-import-vars/README.md#limitations
|
||||
window.module ??= {};
|
||||
return import(`../../../node_modules/refractor/lang/${language}.js`).then(
|
||||
() => {
|
||||
refractor.register(window.module.exports);
|
||||
return language;
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
// It will retry loading the language on the next render
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to load language ${language} for code highlighting`,
|
||||
err
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function getDecorations({
|
||||
doc,
|
||||
@@ -57,12 +85,7 @@ function getDecorations({
|
||||
|
||||
blocks.forEach((block) => {
|
||||
let startPos = block.pos + 1;
|
||||
const language = getPrismLangForLanguage(block.node.attrs.language);
|
||||
|
||||
if (!language || !refractor.registered(language)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const language = getRefractorLangForLanguage(block.node.attrs.language);
|
||||
const lineDecorations = [];
|
||||
|
||||
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
|
||||
@@ -91,35 +114,48 @@ function getDecorations({
|
||||
);
|
||||
}
|
||||
|
||||
const nodes = refractor.highlight(block.node.textContent, language);
|
||||
const newDecorations = parseNodes(nodes)
|
||||
.map((node: ParsedNode) => {
|
||||
const from = startPos;
|
||||
const to = from + node.text.length;
|
||||
|
||||
startPos = to;
|
||||
|
||||
return {
|
||||
...node,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
})
|
||||
.filter((node) => node.classes && node.classes.length)
|
||||
.map((node) =>
|
||||
Decoration.inline(node.from, node.to, {
|
||||
class: node.classes.join(" "),
|
||||
})
|
||||
)
|
||||
.concat(lineDecorations);
|
||||
|
||||
cache[block.pos] = {
|
||||
node: block.node,
|
||||
decorations: newDecorations,
|
||||
decorations: lineDecorations,
|
||||
};
|
||||
|
||||
if (!language) {
|
||||
// do nothing
|
||||
} else if (refractor.registered(language)) {
|
||||
languagesToImport.delete(language);
|
||||
|
||||
const nodes = refractor.highlight(block.node.textContent, language);
|
||||
const newDecorations = parseNodes(nodes)
|
||||
.map((node: ParsedNode) => {
|
||||
const from = startPos;
|
||||
const to = from + node.text.length;
|
||||
|
||||
startPos = to;
|
||||
|
||||
return {
|
||||
...node,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
})
|
||||
.filter((node) => node.classes && node.classes.length)
|
||||
.map((node) =>
|
||||
Decoration.inline(node.from, node.to, {
|
||||
class: node.classes.join(" "),
|
||||
})
|
||||
)
|
||||
.concat(lineDecorations);
|
||||
|
||||
cache[block.pos] = {
|
||||
node: block.node,
|
||||
decorations: newDecorations,
|
||||
};
|
||||
} else {
|
||||
languagesToImport.add(language);
|
||||
}
|
||||
}
|
||||
|
||||
cache[block.pos].decorations.forEach((decoration) => {
|
||||
cache[block.pos]?.decorations.forEach((decoration) => {
|
||||
decorations.push(decoration);
|
||||
});
|
||||
});
|
||||
@@ -133,7 +169,7 @@ function getDecorations({
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
export default function Prism({
|
||||
export function CodeHighlighting({
|
||||
name,
|
||||
lineNumbers,
|
||||
}: {
|
||||
@@ -145,7 +181,7 @@ export default function Prism({
|
||||
let highlighted = false;
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey("prism"),
|
||||
key: new PluginKey("codeHighlighting"),
|
||||
state: {
|
||||
init: (_, { doc }) => DecorationSet.create(doc, []),
|
||||
apply: (transaction: Transaction, decorationSet, oldState, state) => {
|
||||
@@ -156,11 +192,13 @@ export default function Prism({
|
||||
|
||||
// @ts-expect-error accessing private field.
|
||||
const isPaste = transaction.meta?.paste;
|
||||
const langLoaded = transaction.getMeta("codeHighlighting")?.langLoaded;
|
||||
|
||||
if (
|
||||
!highlighted ||
|
||||
codeBlockChanged ||
|
||||
isPaste ||
|
||||
langLoaded ||
|
||||
isRemoteTransaction(transaction)
|
||||
) {
|
||||
highlighted = true;
|
||||
@@ -174,15 +212,34 @@ export default function Prism({
|
||||
if (!highlighted) {
|
||||
// we don't highlight code blocks on the first render as part of mounting
|
||||
// as it's expensive (relative to the rest of the document). Instead let
|
||||
// it render un-highlighted and then trigger a defered render of Prism
|
||||
// it render un-highlighted and then trigger a defered render of highlighting
|
||||
// by updating the plugins metadata
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!view.isDestroyed) {
|
||||
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta("codeHighlighting", { loaded: true })
|
||||
);
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
}
|
||||
return {};
|
||||
return {
|
||||
update: () => {
|
||||
if (!languagesToImport.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.all([...languagesToImport].map(loadLanguage)).then(
|
||||
(language) =>
|
||||
languagesToImport.size
|
||||
? view.dispatch(
|
||||
view.state.tr.setMeta("codeHighlighting", {
|
||||
langLoaded: language,
|
||||
})
|
||||
)
|
||||
: null
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
|
||||
import { getRefractorLangForLanguage, getLabelForLanguage } from "./code";
|
||||
|
||||
describe("getPrismLangForLanguage", () => {
|
||||
it("should return the correct Prism language identifier for a given language", () => {
|
||||
expect(getPrismLangForLanguage("javascript")).toBe("javascript");
|
||||
expect(getPrismLangForLanguage("mermaidjs")).toBe("mermaid");
|
||||
expect(getPrismLangForLanguage("xml")).toBe("markup");
|
||||
expect(getPrismLangForLanguage("unknown")).toBeUndefined();
|
||||
expect(getPrismLangForLanguage("")).toBeUndefined();
|
||||
describe("getRefractorLangForLanguage", () => {
|
||||
it("should return the correct lang identifier for a given language", () => {
|
||||
expect(getRefractorLangForLanguage("javascript")).toBe("javascript");
|
||||
expect(getRefractorLangForLanguage("mermaidjs")).toBe("mermaid");
|
||||
expect(getRefractorLangForLanguage("xml")).toBe("markup");
|
||||
expect(getRefractorLangForLanguage("unknown")).toBeUndefined();
|
||||
expect(getRefractorLangForLanguage("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+20
-11
@@ -1,6 +1,6 @@
|
||||
import Storage from "../../utils/Storage";
|
||||
|
||||
const RecentStorageKey = "rme-code-language";
|
||||
const RecentlyUsedStorageKey = "rme-code-language";
|
||||
const StorageKey = "frequent-code-languages";
|
||||
const frequentLanguagesToGet = 5;
|
||||
const frequentLanguagesToTrack = 10;
|
||||
@@ -9,7 +9,7 @@ const frequentLanguagesToTrack = 10;
|
||||
* List of supported code languages.
|
||||
*
|
||||
* Object key is the language identifier used in the editor, lang is the
|
||||
* language identifier used by Prism. Note mismatches such as `markup` and
|
||||
* language identifier used by Refractor. Note mismatches such as `markup` and
|
||||
* `mermaid`.
|
||||
*/
|
||||
export const codeLanguages = {
|
||||
@@ -19,8 +19,10 @@ export const codeLanguages = {
|
||||
cpp: { lang: "cpp", label: "C++" },
|
||||
csharp: { lang: "csharp", label: "C#" },
|
||||
css: { lang: "css", label: "CSS" },
|
||||
csv: { lang: "csv", label: "CSV" },
|
||||
docker: { lang: "docker", label: "Docker" },
|
||||
elixir: { lang: "elixir", label: "Elixir" },
|
||||
erb: { lang: "erb", label: "ERB" },
|
||||
erlang: { lang: "erlang", label: "Erlang" },
|
||||
go: { lang: "go", label: "Go" },
|
||||
graphql: { lang: "graphql", label: "GraphQL" },
|
||||
@@ -34,8 +36,11 @@ export const codeLanguages = {
|
||||
json: { lang: "json", label: "JSON" },
|
||||
jsx: { lang: "jsx", label: "JSX" },
|
||||
kotlin: { lang: "kotlin", label: "Kotlin" },
|
||||
kusto: { lang: "kusto", label: "Kusto" },
|
||||
lisp: { lang: "lisp", label: "Lisp" },
|
||||
lua: { lang: "lua", label: "Lua" },
|
||||
makefile: { lang: "makefile", label: "Makefile" },
|
||||
markdown: { lang: "markdown", label: "Markdown" },
|
||||
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
|
||||
nginx: { lang: "nginx", label: "Nginx" },
|
||||
nix: { lang: "nix", label: "Nix" },
|
||||
@@ -47,11 +52,13 @@ export const codeLanguages = {
|
||||
protobuf: { lang: "protobuf", label: "Protobuf" },
|
||||
python: { lang: "python", label: "Python" },
|
||||
r: { lang: "r", label: "R" },
|
||||
regex: { lang: "regex", label: "Regex" },
|
||||
ruby: { lang: "ruby", label: "Ruby" },
|
||||
rust: { lang: "rust", label: "Rust" },
|
||||
scala: { lang: "scala", label: "Scala" },
|
||||
sass: { lang: "sass", label: "Sass" },
|
||||
scss: { lang: "scss", label: "SCSS" },
|
||||
"splunk-spl": { lang: "splunk-spl", label: "Splunk SPL" },
|
||||
sql: { lang: "sql", label: "SQL" },
|
||||
solidity: { lang: "solidity", label: "Solidity" },
|
||||
swift: { lang: "swift", label: "Swift" },
|
||||
@@ -79,12 +86,14 @@ export const getLabelForLanguage = (language: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the Prism language identifier for a given language.
|
||||
* Get the Refractor language identifier for a given language.
|
||||
*
|
||||
* @param language The language identifier.
|
||||
* @returns The Prism language identifier for the language.
|
||||
* @returns The Refractor language identifier for the language.
|
||||
*/
|
||||
export const getPrismLangForLanguage = (language: string): string | undefined =>
|
||||
export const getRefractorLangForLanguage = (
|
||||
language: string
|
||||
): string | undefined =>
|
||||
codeLanguages[language as keyof typeof codeLanguages]?.lang;
|
||||
|
||||
/**
|
||||
@@ -92,14 +101,14 @@ export const getPrismLangForLanguage = (language: string): string | undefined =>
|
||||
*
|
||||
* @param language The language identifier.
|
||||
*/
|
||||
export const setRecentCodeLanguage = (language: string) => {
|
||||
export const setRecentlyUsedCodeLanguage = (language: string) => {
|
||||
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
|
||||
if (Object.keys(frequentLangs).length === 0) {
|
||||
const lastUsedLang = Storage.get(RecentStorageKey);
|
||||
const lastUsedLang = Storage.get(RecentlyUsedStorageKey);
|
||||
if (lastUsedLang) {
|
||||
frequentLangs[lastUsedLang] = 1;
|
||||
}
|
||||
@@ -121,7 +130,7 @@ export const setRecentCodeLanguage = (language: string) => {
|
||||
}
|
||||
|
||||
Storage.set(StorageKey, Object.fromEntries(frequentLangEntries));
|
||||
Storage.set(RecentStorageKey, language);
|
||||
Storage.set(RecentlyUsedStorageKey, language);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -129,8 +138,8 @@ export const setRecentCodeLanguage = (language: string) => {
|
||||
*
|
||||
* @returns The most recent code language used, or undefined if none is set.
|
||||
*/
|
||||
export const getRecentCodeLanguage = () =>
|
||||
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
|
||||
export const getRecentlyUsedCodeLanguage = () =>
|
||||
Storage.get(RecentlyUsedStorageKey) as keyof typeof codeLanguages | undefined;
|
||||
|
||||
/**
|
||||
* Get the most frequent code languages used.
|
||||
@@ -138,7 +147,7 @@ export const getRecentCodeLanguage = () =>
|
||||
* @returns An array of the most frequent code languages used.
|
||||
*/
|
||||
export const getFrequentCodeLanguages = () => {
|
||||
const recentLang = Storage.get(RecentStorageKey);
|
||||
const recentLang = Storage.get(RecentlyUsedStorageKey);
|
||||
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
|
||||
keyof typeof codeLanguages,
|
||||
number
|
||||
|
||||
@@ -9,58 +9,6 @@ import {
|
||||
} from "prosemirror-model";
|
||||
import { Command, Plugin, PluginKey, TextSelection } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
import refractor from "refractor/core";
|
||||
import bash from "refractor/lang/bash";
|
||||
import clike from "refractor/lang/clike";
|
||||
import cpp from "refractor/lang/cpp";
|
||||
import csharp from "refractor/lang/csharp";
|
||||
import css from "refractor/lang/css";
|
||||
import docker from "refractor/lang/docker";
|
||||
import elixir from "refractor/lang/elixir";
|
||||
import erlang from "refractor/lang/erlang";
|
||||
import go from "refractor/lang/go";
|
||||
import graphql from "refractor/lang/graphql";
|
||||
import groovy from "refractor/lang/groovy";
|
||||
import haskell from "refractor/lang/haskell";
|
||||
import hcl from "refractor/lang/hcl";
|
||||
import ini from "refractor/lang/ini";
|
||||
import java from "refractor/lang/java";
|
||||
import javascript from "refractor/lang/javascript";
|
||||
import json from "refractor/lang/json";
|
||||
import jsx from "refractor/lang/jsx";
|
||||
import kotlin from "refractor/lang/kotlin";
|
||||
import lisp from "refractor/lang/lisp";
|
||||
import lua from "refractor/lang/lua";
|
||||
import markup from "refractor/lang/markup";
|
||||
// @ts-expect-error type definition is missing, but package exists
|
||||
import mermaid from "refractor/lang/mermaid";
|
||||
import nginx from "refractor/lang/nginx";
|
||||
import nix from "refractor/lang/nix";
|
||||
import objectivec from "refractor/lang/objectivec";
|
||||
import ocaml from "refractor/lang/ocaml";
|
||||
import perl from "refractor/lang/perl";
|
||||
import php from "refractor/lang/php";
|
||||
import powershell from "refractor/lang/powershell";
|
||||
import protobuf from "refractor/lang/protobuf";
|
||||
import python from "refractor/lang/python";
|
||||
import r from "refractor/lang/r";
|
||||
import ruby from "refractor/lang/ruby";
|
||||
import rust from "refractor/lang/rust";
|
||||
import sass from "refractor/lang/sass";
|
||||
import scala from "refractor/lang/scala";
|
||||
import scss from "refractor/lang/scss";
|
||||
import solidity from "refractor/lang/solidity";
|
||||
import sql from "refractor/lang/sql";
|
||||
import swift from "refractor/lang/swift";
|
||||
import toml from "refractor/lang/toml";
|
||||
import tsx from "refractor/lang/tsx";
|
||||
import typescript from "refractor/lang/typescript";
|
||||
import verilog from "refractor/lang/verilog";
|
||||
import vhdl from "refractor/lang/vhdl";
|
||||
import visualbasic from "refractor/lang/visual-basic";
|
||||
import yaml from "refractor/lang/yaml";
|
||||
import zig from "refractor/lang/zig";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { Primitive } from "utility-types";
|
||||
import type { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -77,9 +25,12 @@ import {
|
||||
} from "../commands/codeFence";
|
||||
import { selectAll } from "../commands/selectAll";
|
||||
import toggleBlockType from "../commands/toggleBlockType";
|
||||
import { CodeHighlighting } from "../extensions/CodeHighlighting";
|
||||
import Mermaid from "../extensions/Mermaid";
|
||||
import Prism from "../extensions/Prism";
|
||||
import { getRecentCodeLanguage, setRecentCodeLanguage } from "../lib/code";
|
||||
import {
|
||||
getRecentlyUsedCodeLanguage,
|
||||
setRecentlyUsedCodeLanguage,
|
||||
} from "../lib/code";
|
||||
import { isCode } from "../lib/isCode";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
|
||||
@@ -90,58 +41,6 @@ import Node from "./Node";
|
||||
|
||||
const DEFAULT_LANGUAGE = "javascript";
|
||||
|
||||
[
|
||||
bash,
|
||||
cpp,
|
||||
css,
|
||||
clike,
|
||||
csharp,
|
||||
docker,
|
||||
elixir,
|
||||
erlang,
|
||||
go,
|
||||
graphql,
|
||||
groovy,
|
||||
haskell,
|
||||
hcl,
|
||||
ini,
|
||||
java,
|
||||
javascript,
|
||||
jsx,
|
||||
json,
|
||||
kotlin,
|
||||
lisp,
|
||||
lua,
|
||||
markup,
|
||||
mermaid,
|
||||
nginx,
|
||||
nix,
|
||||
objectivec,
|
||||
ocaml,
|
||||
perl,
|
||||
php,
|
||||
python,
|
||||
powershell,
|
||||
protobuf,
|
||||
r,
|
||||
ruby,
|
||||
rust,
|
||||
scala,
|
||||
sql,
|
||||
solidity,
|
||||
sass,
|
||||
scss,
|
||||
swift,
|
||||
toml,
|
||||
typescript,
|
||||
tsx,
|
||||
verilog,
|
||||
vhdl,
|
||||
visualbasic,
|
||||
yaml,
|
||||
zig,
|
||||
].forEach(refractor.register);
|
||||
|
||||
export default class CodeFence extends Node {
|
||||
constructor(options: {
|
||||
dictionary: Dictionary;
|
||||
@@ -212,10 +111,10 @@ export default class CodeFence extends Node {
|
||||
return {
|
||||
code_block: (attrs: Record<string, Primitive>) => {
|
||||
if (attrs?.language) {
|
||||
setRecentCodeLanguage(attrs.language as string);
|
||||
setRecentlyUsedCodeLanguage(attrs.language as string);
|
||||
}
|
||||
return toggleBlockType(type, schema.nodes.paragraph, {
|
||||
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
...attrs,
|
||||
});
|
||||
},
|
||||
@@ -286,7 +185,7 @@ export default class CodeFence extends Node {
|
||||
|
||||
get plugins() {
|
||||
return [
|
||||
Prism({
|
||||
CodeHighlighting({
|
||||
name: this.name,
|
||||
lineNumbers: this.showLineNumbers,
|
||||
}),
|
||||
@@ -353,7 +252,7 @@ export default class CodeFence extends Node {
|
||||
inputRules({ type }: { type: NodeType }) {
|
||||
return [
|
||||
textblockTypeInputRule(/^```$/, type, () => ({
|
||||
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import isMatch from "lodash/isMatch";
|
||||
import { Token } from "markdown-it";
|
||||
import {
|
||||
NodeSpec,
|
||||
@@ -15,10 +16,18 @@ import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import { MentionType } from "../../types";
|
||||
import {
|
||||
MentionPlaceholder,
|
||||
MentionType,
|
||||
UnfurlResourceType,
|
||||
UnfurlResponse,
|
||||
} from "../../types";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
MentionIssue,
|
||||
MentionPlaceholder as MentionPlaceholderComp,
|
||||
MentionPullRequest,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
@@ -50,6 +59,12 @@ export default class Mention extends Node {
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
href: {
|
||||
default: undefined,
|
||||
},
|
||||
unfurl: {
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
inline: true,
|
||||
marks: "",
|
||||
@@ -73,6 +88,10 @@ export default class Mention extends Node {
|
||||
actorId: dom.dataset.actorid,
|
||||
label: dom.innerText,
|
||||
id: dom.id,
|
||||
href: dom.getAttribute("href"),
|
||||
unfurl: dom.dataset.unfurl
|
||||
? JSON.parse(dom.dataset.unfurl)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -87,11 +106,18 @@ export default class Mention extends Node {
|
||||
? undefined
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
: `${env.URL}/collection/${node.attrs.modelId}`,
|
||||
: node.attrs.type === MentionType.Collection
|
||||
? `${env.URL}/collection/${node.attrs.modelId}`
|
||||
: node.attrs.href,
|
||||
"data-type": node.attrs.type,
|
||||
"data-id": node.attrs.modelId,
|
||||
"data-actorid": node.attrs.actorId,
|
||||
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
"data-url":
|
||||
node.attrs.type === MentionType.PullRequest ||
|
||||
node.attrs.type === MentionType.Issue
|
||||
? node.attrs.href
|
||||
: `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
|
||||
"data-unfurl": JSON.stringify(node.attrs.unfurl),
|
||||
},
|
||||
toPlainText(node),
|
||||
],
|
||||
@@ -107,6 +133,22 @@ export default class Mention extends Node {
|
||||
return <MentionDocument {...props} />;
|
||||
case MentionType.Collection:
|
||||
return <MentionCollection {...props} />;
|
||||
case MentionType.Issue:
|
||||
return (
|
||||
<MentionIssue
|
||||
{...props}
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionType.PullRequest:
|
||||
return (
|
||||
<MentionPullRequest
|
||||
{...props}
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionPlaceholder:
|
||||
return <MentionPlaceholderComp />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -149,29 +191,42 @@ export default class Mention extends Node {
|
||||
}
|
||||
|
||||
keys(): Record<string, Command> {
|
||||
const NavigableMention = [
|
||||
MentionType.Collection,
|
||||
MentionType.Document,
|
||||
MentionType.Issue,
|
||||
MentionType.PullRequest,
|
||||
];
|
||||
|
||||
return {
|
||||
Enter: (state) => {
|
||||
const { selection } = state;
|
||||
if (
|
||||
selection instanceof NodeSelection &&
|
||||
selection.node.type.name === this.name &&
|
||||
(selection.node.attrs.type === MentionType.Document ||
|
||||
selection.node.attrs.type === MentionType.Collection)
|
||||
NavigableMention.includes(selection.node.attrs.type)
|
||||
) {
|
||||
const { modelId } = selection.node.attrs;
|
||||
const mentionType = selection.node.attrs.type;
|
||||
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: selection.node.attrs.type === MentionType.Collection
|
||||
? "collection"
|
||||
: undefined;
|
||||
let link: string;
|
||||
|
||||
if (!linkType) {
|
||||
return false;
|
||||
if (
|
||||
mentionType === MentionType.Issue ||
|
||||
mentionType === MentionType.PullRequest
|
||||
) {
|
||||
link = selection.node.attrs.href;
|
||||
} else {
|
||||
const { modelId } = selection.node.attrs;
|
||||
|
||||
const linkType =
|
||||
selection.node.attrs.type === MentionType.Document
|
||||
? "doc"
|
||||
: "collection";
|
||||
|
||||
link = `/${linkType}/${modelId}`;
|
||||
}
|
||||
|
||||
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
|
||||
this.editor.props.onClickLink?.(link);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -218,4 +273,30 @@ export default class Mention extends Node {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeUnfurl =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
|
||||
const label =
|
||||
unfurl.type === UnfurlResourceType.Issue ||
|
||||
unfurl.type === UnfurlResourceType.PR
|
||||
? unfurl.title
|
||||
: undefined;
|
||||
|
||||
const overrides: Record<string, unknown> = label ? { label } : {};
|
||||
overrides.unfurl = unfurl;
|
||||
|
||||
const pos = getPos();
|
||||
|
||||
if (!isMatch(node.attrs, overrides)) {
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
...overrides,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* 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, []);
|
||||
}
|
||||
@@ -57,6 +57,8 @@
|
||||
"Download document": "Download document",
|
||||
"Copy as Markdown": "Copy as Markdown",
|
||||
"Markdown copied to clipboard": "Markdown copied to clipboard",
|
||||
"Copy as text": "Copy as text",
|
||||
"Text copied to clipboard": "Text copied to clipboard",
|
||||
"Copy public link": "Copy public link",
|
||||
"Link copied to clipboard": "Link copied to clipboard",
|
||||
"Copy link": "Copy link",
|
||||
@@ -426,6 +428,7 @@
|
||||
"Create a new doc": "Create a new doc",
|
||||
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
|
||||
"Keep as link": "Keep as link",
|
||||
"Mention": "Mention",
|
||||
"Embed": "Embed",
|
||||
"Add column after": "Add column after",
|
||||
"Add column before": "Add column before",
|
||||
@@ -891,6 +894,8 @@
|
||||
"No people left to add": "No people left to add",
|
||||
"Date created": "Date created",
|
||||
"Upload": "Upload",
|
||||
"Crop image": "Crop image",
|
||||
"Uploading": "Uploading",
|
||||
"How does this work?": "How does this work?",
|
||||
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
|
||||
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
|
||||
@@ -1152,5 +1157,5 @@
|
||||
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
|
||||
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
|
||||
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
|
||||
"Uploading": "Uploading"
|
||||
"Error loading data": "Error loading data"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CollectionPermission,
|
||||
type ImportableIntegrationService,
|
||||
IntegrationService,
|
||||
IssueProviderIntegrationService,
|
||||
ProsemirrorDoc,
|
||||
} from "./types";
|
||||
import { PageType } from "plugins/notion/shared/types";
|
||||
@@ -57,3 +58,15 @@ export type ImportTaskOutput = {
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}[];
|
||||
|
||||
export const IssueSource = z.object({
|
||||
id: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
owner: z.object({
|
||||
id: z.string().nonempty(),
|
||||
name: z.string().nonempty(),
|
||||
}),
|
||||
service: z.nativeEnum(IssueProviderIntegrationService),
|
||||
});
|
||||
|
||||
export type IssueSource = z.infer<typeof IssueSource>;
|
||||
|
||||
+15
-2
@@ -75,8 +75,12 @@ export enum MentionType {
|
||||
User = "user",
|
||||
Document = "document",
|
||||
Collection = "collection",
|
||||
Issue = "issue",
|
||||
PullRequest = "pull_request",
|
||||
}
|
||||
|
||||
export const MentionPlaceholder = "mention_placeholder";
|
||||
|
||||
export type PublicEnv = {
|
||||
ROOT_SHARE_ID?: string;
|
||||
analytics: {
|
||||
@@ -144,6 +148,15 @@ export const UserCreatableIntegrationService = {
|
||||
Umami: IntegrationService.Umami,
|
||||
} as const;
|
||||
|
||||
export type IssueProviderIntegrationService = Extract<
|
||||
IntegrationService,
|
||||
IntegrationService.GitHub
|
||||
>;
|
||||
|
||||
export const IssueProviderIntegrationService = {
|
||||
Notion: IntegrationService.GitHub,
|
||||
} as const;
|
||||
|
||||
export enum CollectionPermission {
|
||||
Read = "read",
|
||||
ReadWrite = "read_write",
|
||||
@@ -416,7 +429,7 @@ export type UnfurlResponse = {
|
||||
/** Issue title */
|
||||
title: string;
|
||||
/** Issue description */
|
||||
description: string;
|
||||
description: string | null;
|
||||
/** Issue's author */
|
||||
author: { name: string; avatarUrl: string };
|
||||
/** Issue's labels */
|
||||
@@ -436,7 +449,7 @@ export type UnfurlResponse = {
|
||||
/** Pull Request title */
|
||||
title: string;
|
||||
/** Pull Request description */
|
||||
description: string;
|
||||
description: string | null;
|
||||
/** Pull Request author */
|
||||
author: { name: string; avatarUrl: string };
|
||||
/** Pull Request status */
|
||||
|
||||
@@ -105,16 +105,16 @@
|
||||
"@smithy/util-utf8" "^2.0.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/client-s3@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.777.0.tgz#50e14ced9c1667b14c7dea79b4cc19cd58c58916"
|
||||
integrity sha512-KVX2QD6lLczZxtzIRCpmztgNnGq+spiMIDYqkum/rCBjCX1YJoDHwMYXaMf2EtAH8tFkJmBiA/CiT/J36iN7Xg==
|
||||
"@aws-sdk/client-s3@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.782.0.tgz#ed807a1cd9a3e7a55bf41dfb486da0bbd1f4852d"
|
||||
integrity sha512-V6JR2JAGYQY7J8wk5un5n/ja2nfCUyyoRCF8Du8JL91NGI8i41Mdr/TzuOGwTgFl6RSXb/ge1K1jk30OH4MugQ==
|
||||
dependencies:
|
||||
"@aws-crypto/sha1-browser" "5.2.0"
|
||||
"@aws-crypto/sha256-browser" "5.2.0"
|
||||
"@aws-crypto/sha256-js" "5.2.0"
|
||||
"@aws-sdk/core" "3.775.0"
|
||||
"@aws-sdk/credential-provider-node" "3.777.0"
|
||||
"@aws-sdk/credential-provider-node" "3.782.0"
|
||||
"@aws-sdk/middleware-bucket-endpoint" "3.775.0"
|
||||
"@aws-sdk/middleware-expect-continue" "3.775.0"
|
||||
"@aws-sdk/middleware-flexible-checksums" "3.775.0"
|
||||
@@ -124,13 +124,13 @@
|
||||
"@aws-sdk/middleware-recursion-detection" "3.775.0"
|
||||
"@aws-sdk/middleware-sdk-s3" "3.775.0"
|
||||
"@aws-sdk/middleware-ssec" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.782.0"
|
||||
"@aws-sdk/region-config-resolver" "3.775.0"
|
||||
"@aws-sdk/signature-v4-multi-region" "3.775.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.782.0"
|
||||
"@aws-sdk/util-user-agent-browser" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.782.0"
|
||||
"@aws-sdk/xml-builder" "3.775.0"
|
||||
"@smithy/config-resolver" "^4.1.0"
|
||||
"@smithy/core" "^3.2.0"
|
||||
@@ -167,10 +167,10 @@
|
||||
"@smithy/util-waiter" "^4.0.3"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/client-sso@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.777.0.tgz#75582dad211497ab7e52ac2d51d7eb3e7954f5e6"
|
||||
integrity sha512-0+z6CiAYIQa7s6FJ+dpBYPi9zr9yY5jBg/4/FGcwYbmqWPXwL9Thdtr0FearYRZgKl7bhL3m3dILCCfWqr3teQ==
|
||||
"@aws-sdk/client-sso@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/client-sso/-/client-sso-3.782.0.tgz#072cbb23a90ec6fd53145ff75bef8329a857f362"
|
||||
integrity sha512-5GlJBejo8wqMpSSEKb45WE82YxI2k73YuebjLH/eWDNQeE6VI5Bh9lA1YQ7xNkLLH8hIsb0pSfKVuwh0VEzVrg==
|
||||
dependencies:
|
||||
"@aws-crypto/sha256-browser" "5.2.0"
|
||||
"@aws-crypto/sha256-js" "5.2.0"
|
||||
@@ -178,12 +178,12 @@
|
||||
"@aws-sdk/middleware-host-header" "3.775.0"
|
||||
"@aws-sdk/middleware-logger" "3.775.0"
|
||||
"@aws-sdk/middleware-recursion-detection" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.782.0"
|
||||
"@aws-sdk/region-config-resolver" "3.775.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.782.0"
|
||||
"@aws-sdk/util-user-agent-browser" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.782.0"
|
||||
"@smithy/config-resolver" "^4.1.0"
|
||||
"@smithy/core" "^3.2.0"
|
||||
"@smithy/fetch-http-handler" "^5.0.2"
|
||||
@@ -255,18 +255,18 @@
|
||||
"@smithy/util-stream" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/credential-provider-ini@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.777.0.tgz#bd508b1e8d421ac5009d7f756a826796817970d9"
|
||||
integrity sha512-1X9mCuM9JSQPmQ+D2TODt4THy6aJWCNiURkmKmTIPRdno7EIKgAqrr/LLN++K5mBf54DZVKpqcJutXU2jwo01A==
|
||||
"@aws-sdk/credential-provider-ini@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.782.0.tgz#4ffd90f6b3b6b34d1dabcba875e5d00fc5da23f1"
|
||||
integrity sha512-wd4KdRy2YjLsE4Y7pz00470Iip06GlRHkG4dyLW7/hFMzEO2o7ixswCWp6J2VGZVAX64acknlv2Q0z02ebjmhw==
|
||||
dependencies:
|
||||
"@aws-sdk/core" "3.775.0"
|
||||
"@aws-sdk/credential-provider-env" "3.775.0"
|
||||
"@aws-sdk/credential-provider-http" "3.775.0"
|
||||
"@aws-sdk/credential-provider-process" "3.775.0"
|
||||
"@aws-sdk/credential-provider-sso" "3.777.0"
|
||||
"@aws-sdk/credential-provider-web-identity" "3.777.0"
|
||||
"@aws-sdk/nested-clients" "3.777.0"
|
||||
"@aws-sdk/credential-provider-sso" "3.782.0"
|
||||
"@aws-sdk/credential-provider-web-identity" "3.782.0"
|
||||
"@aws-sdk/nested-clients" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/credential-provider-imds" "^4.0.2"
|
||||
"@smithy/property-provider" "^4.0.2"
|
||||
@@ -274,17 +274,17 @@
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/credential-provider-node@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.777.0.tgz#65e6de233a8d885c63f0362968239bac61001c4f"
|
||||
integrity sha512-ZD66ywx1Q0KyUSuBXZIQzBe3Q7MzX8lNwsrCU43H3Fww+Y+HB3Ncws9grhSdNhKQNeGmZ+MgKybuZYaaeLwJEQ==
|
||||
"@aws-sdk/credential-provider-node@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.782.0.tgz#81a798710d0567b26cd20a105790b49586e68d40"
|
||||
integrity sha512-HZiAF+TCEyKjju9dgysjiPIWgt/+VerGaeEp18mvKLNfgKz1d+/82A2USEpNKTze7v3cMFASx3CvL8yYyF7mJw==
|
||||
dependencies:
|
||||
"@aws-sdk/credential-provider-env" "3.775.0"
|
||||
"@aws-sdk/credential-provider-http" "3.775.0"
|
||||
"@aws-sdk/credential-provider-ini" "3.777.0"
|
||||
"@aws-sdk/credential-provider-ini" "3.782.0"
|
||||
"@aws-sdk/credential-provider-process" "3.775.0"
|
||||
"@aws-sdk/credential-provider-sso" "3.777.0"
|
||||
"@aws-sdk/credential-provider-web-identity" "3.777.0"
|
||||
"@aws-sdk/credential-provider-sso" "3.782.0"
|
||||
"@aws-sdk/credential-provider-web-identity" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/credential-provider-imds" "^4.0.2"
|
||||
"@smithy/property-provider" "^4.0.2"
|
||||
@@ -304,45 +304,45 @@
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/credential-provider-sso@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.777.0.tgz#570779b2bc2f4181024b1af81bf0a5bd65e7c15c"
|
||||
integrity sha512-9mPz7vk9uE4PBVprfINv4tlTkyq1OonNevx2DiXC1LY4mCUCNN3RdBwAY0BTLzj0uyc3k5KxFFNbn3/8ZDQP7w==
|
||||
"@aws-sdk/credential-provider-sso@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.782.0.tgz#f644884cae368204f750c35d8a61a04d77c02674"
|
||||
integrity sha512-1y1ucxTtTIGDSNSNxriQY8msinilhe9gGvQpUDYW9gboyC7WQJPDw66imy258V6osdtdi+xoHzVCbCz3WhosMQ==
|
||||
dependencies:
|
||||
"@aws-sdk/client-sso" "3.777.0"
|
||||
"@aws-sdk/client-sso" "3.782.0"
|
||||
"@aws-sdk/core" "3.775.0"
|
||||
"@aws-sdk/token-providers" "3.777.0"
|
||||
"@aws-sdk/token-providers" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/property-provider" "^4.0.2"
|
||||
"@smithy/shared-ini-file-loader" "^4.0.2"
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.777.0.tgz#4f36ccd876fd045e2bfd7678287d381a5430e7b9"
|
||||
integrity sha512-uGCqr47fnthkqwq5luNl2dksgcpHHjSXz2jUra7TXtFOpqvnhOW8qXjoa1ivlkq8qhqlaZwCzPdbcN0lXpmLzQ==
|
||||
"@aws-sdk/credential-provider-web-identity@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.782.0.tgz#5dbab53a7b49dcf8390d71415855e78b911a4742"
|
||||
integrity sha512-xCna0opVPaueEbJoclj5C6OpDNi0Gynj+4d7tnuXGgQhTHPyAz8ZyClkVqpi5qvHTgxROdUEDxWqEO5jqRHZHQ==
|
||||
dependencies:
|
||||
"@aws-sdk/core" "3.775.0"
|
||||
"@aws-sdk/nested-clients" "3.777.0"
|
||||
"@aws-sdk/nested-clients" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/property-provider" "^4.0.2"
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/crt-loader@3.775.0":
|
||||
version "3.775.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/crt-loader/-/crt-loader-3.775.0.tgz#9c5cdc7d33230a4995d1b1ecc9b6752e39bc0b3f"
|
||||
integrity sha512-jSlyWzJ5E4e/Rz7TaFvTj4rNelZeBTixaEkg0o4Cnu5SuIWeEk4VRtHuTA1ygbja4OHFxX7S3ntzH35xfJVf1w==
|
||||
"@aws-sdk/crt-loader@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/crt-loader/-/crt-loader-3.782.0.tgz#a2f8d34a22155d9730426cb727694c54dc0299c4"
|
||||
integrity sha512-S3fDfZfVhGBvyseJEIcVwa7pumyjqhMKIGHYzcxpthBptJ9gcpYgyHSC6Z7hguQfsfurawe7MNvFnx8O6oLFLA==
|
||||
dependencies:
|
||||
"@aws-sdk/util-user-agent-node" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.782.0"
|
||||
aws-crt "^1.24.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/lib-storage@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/lib-storage/-/lib-storage-3.777.0.tgz#8e57c704775b114300576d7672d8b16af6ad7666"
|
||||
integrity sha512-mX9LcdJe9QeQwo9WPejpEglHobpTYb2kHT4q2k4JMKu9npNF0HlF0hrW3rNNEfHH/4mlj1NrA216LgXYp1+YmA==
|
||||
"@aws-sdk/lib-storage@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/lib-storage/-/lib-storage-3.782.0.tgz#721fd62f01805a9eed661f19f556dc7c6f17a70e"
|
||||
integrity sha512-UQYnIzpBReLko2XhDgG/rWpoHTWv4/zqUNl4XJXZRo9akLzrxGKtPrp5nJ4OLUkH3tIm1cvmI3XlSjHUW/OxWw==
|
||||
dependencies:
|
||||
"@smithy/abort-controller" "^4.0.2"
|
||||
"@smithy/middleware-endpoint" "^4.1.0"
|
||||
@@ -461,23 +461,23 @@
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/middleware-user-agent@3.775.0":
|
||||
version "3.775.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.775.0.tgz#66950672df55ddb32062baa4d92c67b3b67dfa65"
|
||||
integrity sha512-7Lffpr1ptOEDE1ZYH1T78pheEY1YmeXWBfFt/amZ6AGsKSLG+JPXvof3ltporTGR2bhH/eJPo7UHCglIuXfzYg==
|
||||
"@aws-sdk/middleware-user-agent@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.782.0.tgz#60e47e365a39cfa64aa81c3c881896face74da45"
|
||||
integrity sha512-i32H2R6IItX+bQ2p4+v2gGO2jA80jQoJO2m1xjU9rYWQW3+ErWy4I5YIuQHTBfb6hSdAHbaRfqPDgbv9J2rjEg==
|
||||
dependencies:
|
||||
"@aws-sdk/core" "3.775.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.782.0"
|
||||
"@smithy/core" "^3.2.0"
|
||||
"@smithy/protocol-http" "^5.1.0"
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/nested-clients@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.777.0.tgz#e3b72bdebe4b60364ff82aff6c272aa163404196"
|
||||
integrity sha512-bmmVRsCjuYlStYPt06hr+f8iEyWg7+AklKCA8ZLDEJujXhXIowgUIqXmqpTkXwkVvDQ9tzU7hxaONjyaQCGybA==
|
||||
"@aws-sdk/nested-clients@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.782.0.tgz#73f56fc4d22e1be342e00b7eba9de4d192521a05"
|
||||
integrity sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA==
|
||||
dependencies:
|
||||
"@aws-crypto/sha256-browser" "5.2.0"
|
||||
"@aws-crypto/sha256-js" "5.2.0"
|
||||
@@ -485,12 +485,12 @@
|
||||
"@aws-sdk/middleware-host-header" "3.775.0"
|
||||
"@aws-sdk/middleware-logger" "3.775.0"
|
||||
"@aws-sdk/middleware-recursion-detection" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.782.0"
|
||||
"@aws-sdk/region-config-resolver" "3.775.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.775.0"
|
||||
"@aws-sdk/util-endpoints" "3.782.0"
|
||||
"@aws-sdk/util-user-agent-browser" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.775.0"
|
||||
"@aws-sdk/util-user-agent-node" "3.782.0"
|
||||
"@smithy/config-resolver" "^4.1.0"
|
||||
"@smithy/core" "^3.2.0"
|
||||
"@smithy/fetch-http-handler" "^5.0.2"
|
||||
@@ -530,12 +530,12 @@
|
||||
"@smithy/util-middleware" "^4.0.2"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/s3-presigned-post@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.777.0.tgz#6bbd3c897d5d60fd3937bead299b765285237560"
|
||||
integrity sha512-1fLlgRPPTEWlAxhl3KxJ0z+2vJXg21WFIh+16EUiY1T1vO9bHFbxM0t0Ea0RQxlv1ndxvAs0f2Aov3tXNIvagQ==
|
||||
"@aws-sdk/s3-presigned-post@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.782.0.tgz#bf6fd60c20a511e6e039c6740f1a80a078d2469e"
|
||||
integrity sha512-JlgZ3wZ3wId0zT0hiEkL6GapAWWh6m4P3uJsDWpcSv1yOA+9bqA3fLcxofnNodL2/7fxam2elI+jVvx/wXIqlQ==
|
||||
dependencies:
|
||||
"@aws-sdk/client-s3" "3.777.0"
|
||||
"@aws-sdk/client-s3" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@aws-sdk/util-format-url" "3.775.0"
|
||||
"@smithy/middleware-endpoint" "^4.1.0"
|
||||
@@ -545,10 +545,10 @@
|
||||
"@smithy/util-utf8" "^4.0.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/s3-request-presigner@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.777.0.tgz#8bceed861953545582c6a7141c0ce6a86c817f54"
|
||||
integrity sha512-pmGXG51DFQ8cBpFJbZOkoXKScm+rGvBgfExxkUR35VCo7b7hbhpUcN1t8XSzpgkdK/nxpbnQ6qGAmbIMUCwqqQ==
|
||||
"@aws-sdk/s3-request-presigner@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.782.0.tgz#bc71d5b9377f5c9e4170368da56745bb5164e466"
|
||||
integrity sha512-Er8hdjc9zkxTh15MjdnMYggtUrGknDiuD1FwdW035kn/kwWop587G9rnRa1crhmyKRjLMn0Ki3fsyFUm/943XA==
|
||||
dependencies:
|
||||
"@aws-sdk/signature-v4-multi-region" "3.775.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
@@ -559,12 +559,12 @@
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/signature-v4-crt@^3.775.0":
|
||||
version "3.775.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-crt/-/signature-v4-crt-3.775.0.tgz#61abb089cefd0fe7d87c96b5354824a2164e2a9e"
|
||||
integrity sha512-3850y7mEE2ILYRJgLyi+sjXsjx2S+Lqqv/xw2rfIyB1H+1U1zd9A0sRGkj4JsKZHIvkI1XI8WVQk/1YR36NhPQ==
|
||||
"@aws-sdk/signature-v4-crt@^3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-crt/-/signature-v4-crt-3.782.0.tgz#c90ef0f30bd73a0b9fcdaf31049a2adc7c15b405"
|
||||
integrity sha512-QuVSoJITPZxK0lBA2uIDQFjvLdqEvjXrnLYGpRIvIoHdL4RtcDoad9NRKP6Lb34AoB4tubVF6g85mNNOVsqVAw==
|
||||
dependencies:
|
||||
"@aws-sdk/crt-loader" "3.775.0"
|
||||
"@aws-sdk/crt-loader" "3.782.0"
|
||||
"@aws-sdk/signature-v4-multi-region" "3.775.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/querystring-parser" "^4.0.2"
|
||||
@@ -585,12 +585,12 @@
|
||||
"@smithy/types" "^4.2.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/token-providers@3.777.0":
|
||||
version "3.777.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.777.0.tgz#aeaae6db96e7e00a4d5e3325d0436d5c52310adb"
|
||||
integrity sha512-Yc2cDONsHOa4dTSGOev6Ng2QgTKQUEjaUnsyKd13pc/nLLz/WLqHiQ/o7PcnKERJxXGs1g1C6l3sNXiX+kbnFQ==
|
||||
"@aws-sdk/token-providers@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.782.0.tgz#a6a12f9358f8897f5d1af311da60f90a7d384eac"
|
||||
integrity sha512-4tPuk/3+THPrzKaXW4jE2R67UyGwHLFizZ47pcjJWbhb78IIJAy94vbeqEQ+veS84KF5TXcU7g5jGTXC0D70Wg==
|
||||
dependencies:
|
||||
"@aws-sdk/nested-clients" "3.777.0"
|
||||
"@aws-sdk/nested-clients" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/property-provider" "^4.0.2"
|
||||
"@smithy/shared-ini-file-loader" "^4.0.2"
|
||||
@@ -612,10 +612,10 @@
|
||||
dependencies:
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/util-endpoints@3.775.0":
|
||||
version "3.775.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.775.0.tgz#2f6fd728c86aeb1fba38506161b2eb024de17c19"
|
||||
integrity sha512-yjWmUgZC9tUxAo8Uaplqmq0eUh0zrbZJdwxGRKdYxfm4RG6fMw1tj52+KkatH7o+mNZvg1GDcVp/INktxonJLw==
|
||||
"@aws-sdk/util-endpoints@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.782.0.tgz#3b8de42c5fe951337d070432d4a5eba166c07bb7"
|
||||
integrity sha512-/RJOAO7o7HI6lEa4ASbFFLHGU9iPK876BhsVfnl54MvApPVYWQ9sHO0anOUim2S5lQTwd/6ghuH3rFYSq/+rdw==
|
||||
dependencies:
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/types" "^4.2.0"
|
||||
@@ -649,12 +649,12 @@
|
||||
bowser "^2.11.0"
|
||||
tslib "^2.6.2"
|
||||
|
||||
"@aws-sdk/util-user-agent-node@3.775.0":
|
||||
version "3.775.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.775.0.tgz#dbc34ff2d84e2c3d10466081cad005d49c3d9740"
|
||||
integrity sha512-N9yhTevbizTOMo3drH7Eoy6OkJ3iVPxhV7dwb6CMAObbLneS36CSfA6xQXupmHWcRvZPTz8rd1JGG3HzFOau+g==
|
||||
"@aws-sdk/util-user-agent-node@3.782.0":
|
||||
version "3.782.0"
|
||||
resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.782.0.tgz#795f2c22882f1ddbbe83bd324f0ceac1c4b07c89"
|
||||
integrity sha512-dMFkUBgh2Bxuw8fYZQoH/u3H4afQ12VSkzEi//qFiDTwbKYq+u+RYjc8GLDM6JSK1BShMu5AVR7HD4ap1TYUnA==
|
||||
dependencies:
|
||||
"@aws-sdk/middleware-user-agent" "3.775.0"
|
||||
"@aws-sdk/middleware-user-agent" "3.782.0"
|
||||
"@aws-sdk/types" "3.775.0"
|
||||
"@smithy/node-config-provider" "^4.0.2"
|
||||
"@smithy/types" "^4.2.0"
|
||||
@@ -5315,10 +5315,10 @@
|
||||
"@types/node" "*"
|
||||
form-data "^4.0.0"
|
||||
|
||||
"@types/node@*", "@types/node@20.17.27", "@types/node@>=10.0.0", "@types/node@>=13.7.0":
|
||||
version "20.17.27"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.27.tgz#dbf0f9e6f905e9004045742f94e8413e20bad776"
|
||||
integrity sha512-U58sbKhDrthHlxHRJw7ZLiLDZGmAUOZUbpw0S6nL27sYUdhvgBLCRu/keSd6qcTsfArd1sRFCCBxzWATGr/0UA==
|
||||
"@types/node@*", "@types/node@20.17.30", "@types/node@>=10.0.0", "@types/node@>=13.7.0":
|
||||
version "20.17.30"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.30.tgz#1d93f656d3b869dbef7b796568ac457606ba58d0"
|
||||
integrity sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==
|
||||
dependencies:
|
||||
undici-types "~6.19.2"
|
||||
|
||||
@@ -11215,10 +11215,10 @@ koa-useragent@*, koa-useragent@^4.1.0:
|
||||
dependencies:
|
||||
express-useragent "^1.0.15"
|
||||
|
||||
koa@^2.15.3, koa@^2.15.4:
|
||||
version "2.16.0"
|
||||
resolved "https://registry.yarnpkg.com/koa/-/koa-2.16.0.tgz#0a82ed4d460774ff0b444e361cd6e4bd5c767ee3"
|
||||
integrity sha512-Afhqq0Vq3W7C+/rW6IqHVBDLzqObwZ07JaUNUEF8yCQ6afiyFE3RAy+i7V0E46XOWlH7vPWn/x0vsZwNy6PWxw==
|
||||
koa@^2.15.3, koa@^2.16.1:
|
||||
version "2.16.1"
|
||||
resolved "https://registry.yarnpkg.com/koa/-/koa-2.16.1.tgz#ba1aae04d8319d7dac4a17a0d289d7482501e194"
|
||||
integrity sha512-umfX9d3iuSxTQP4pnzLOz0HKnPg0FaUUIKcye2lOiz3KPu1Y3M3xlz76dISdFPQs37P9eJz1wUpcTS6KDPn9fA==
|
||||
dependencies:
|
||||
accepts "^1.3.5"
|
||||
cache-content-type "^1.0.0"
|
||||
@@ -11618,10 +11618,10 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3
|
||||
dependencies:
|
||||
js-tokens "^3.0.0 || ^4.0.0"
|
||||
|
||||
lop@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/lop/-/lop-0.4.1.tgz#744f1696ef480e68ce1947fe557b09db5af2a738"
|
||||
integrity "sha1-dE8Wlu9IDmjOGUf+VXsJ21rypzg= sha512-9xyho9why2A2tzm5aIcMWKvzqKsnxrf9B5I+8O30olh6lQU8PH978LqZoI4++37RBgS1Em5i54v1TFs/3wnmXQ=="
|
||||
lop@^0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lop/-/lop-0.4.2.tgz#c9c2f958a39b9da1c2f36ca9ad66891a9fe84640"
|
||||
integrity sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==
|
||||
dependencies:
|
||||
duck "^0.1.12"
|
||||
option "~0.2.1"
|
||||
@@ -11737,10 +11737,10 @@ makeerror@1.0.12:
|
||||
dependencies:
|
||||
tmpl "1.0.5"
|
||||
|
||||
mammoth@^1.8.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/mammoth/-/mammoth-1.8.0.tgz#d8f1b0d3a0355fda129270346e9dc853f223028f"
|
||||
integrity sha512-pJNfxSk9IEGVpau+tsZFz22ofjUsl2mnA5eT8PjPs2n0BP+rhVte4Nez6FdgEuxv3IGI3afiV46ImKqTGDVlbA==
|
||||
mammoth@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/mammoth/-/mammoth-1.9.0.tgz#71e34ca280735275788bfe95e653a058dcab4df2"
|
||||
integrity sha512-F+0NxzankQV9XSUAuVKvkdQK0GbtGGuqVnND9aVf9VSeUA82LQa29GjLqYU6Eez8LHqSJG3eGiDW3224OKdpZg==
|
||||
dependencies:
|
||||
"@xmldom/xmldom" "^0.8.6"
|
||||
argparse "~1.0.3"
|
||||
@@ -11748,7 +11748,7 @@ mammoth@^1.8.0:
|
||||
bluebird "~3.4.0"
|
||||
dingbat-to-unicode "^1.0.1"
|
||||
jszip "^3.7.1"
|
||||
lop "^0.4.1"
|
||||
lop "^0.4.2"
|
||||
path-is-absolute "^1.0.0"
|
||||
underscore "^1.13.1"
|
||||
xmlbuilder "^10.0.0"
|
||||
@@ -13090,10 +13090,10 @@ prosemirror-history@^1.4.1:
|
||||
prosemirror-view "^1.31.0"
|
||||
rope-sequence "^1.3.0"
|
||||
|
||||
prosemirror-inputrules@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz#ef1519bb2cb0d1e0cec74bad1a97f1c1555068bb"
|
||||
integrity sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==
|
||||
prosemirror-inputrules@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz#e22bfaf1d6ea4fe240ad447c184af3d520d43c37"
|
||||
integrity sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==
|
||||
dependencies:
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-transform "^1.0.0"
|
||||
@@ -14378,10 +14378,10 @@ socket.io@^4.8.1:
|
||||
socket.io-adapter "~2.5.2"
|
||||
socket.io-parser "~4.2.4"
|
||||
|
||||
sonner@^1.7.1:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/sonner/-/sonner-1.7.1.tgz#737110a3e6211d8d766442076f852ddde1725205"
|
||||
integrity sha512-b6LHBfH32SoVasRFECrdY8p8s7hXPDn3OHUFbZZbiB1ctLS9Gdh6rpX2dVrpQA0kiL5jcRzDDldwwLkSKk3+QQ==
|
||||
sonner@^1.7.4:
|
||||
version "1.7.4"
|
||||
resolved "https://registry.yarnpkg.com/sonner/-/sonner-1.7.4.tgz#4c39820db86623800a17115c8970796aa862133a"
|
||||
integrity sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==
|
||||
|
||||
sort-keys@^5.0.0:
|
||||
version "5.0.0"
|
||||
@@ -15120,16 +15120,11 @@ tslib@2.4.0:
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
|
||||
integrity "sha1-fOyqfwc85oCgWEeqd76UEJjzbcM= sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
|
||||
|
||||
tslib@2.8.1:
|
||||
tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.5.0, tslib@^2.6.2:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
|
||||
tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.5.0, tslib@^2.6.2:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
|
||||
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==
|
||||
|
||||
tsscmp@1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
|
||||
@@ -15610,10 +15605,10 @@ vite-plugin-static-copy@^0.17.0:
|
||||
fs-extra "^11.1.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
vite@^5.4.16:
|
||||
version "5.4.16"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.16.tgz#471983257a890ef33f2700cbbbc2134f2d08abf1"
|
||||
integrity sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==
|
||||
vite@^5.4.18:
|
||||
version "5.4.18"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.18.tgz#b5af357f9d5ebb2e0c085779b7a37a77f09168a4"
|
||||
integrity sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==
|
||||
dependencies:
|
||||
esbuild "^0.21.3"
|
||||
postcss "^8.4.43"
|
||||
|
||||
Reference in New Issue
Block a user