Compare commits

..

4 Commits

128 changed files with 3150 additions and 4759 deletions
+1 -1
View File
@@ -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": ["error", "method"],
"lodash/import-scope": ["warn", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+4 -6
View File
@@ -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=8192
NODE_OPTIONS: --max-old-space-size=8000
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
@@ -63,7 +63,6 @@ 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
@@ -82,7 +81,7 @@ jobs:
- 'yarn.lock'
test:
needs: [build, changes]
needs: build
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -144,8 +143,8 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -162,4 +161,3 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+19 -179
View File
@@ -3,32 +3,25 @@ name: Docker
on:
push:
tags:
- "v*"
- 'v*'
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-arm:
runs-on: ubicloud-standard-8-arm
build-and-push:
runs-on: ubuntu-latest
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:
@@ -36,177 +29,24 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
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/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
- 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: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Build and push
id: build
uses: docker/build-push-action@v6
- name: Build and push main image
uses: docker/build-push-action@v5
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/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 }}
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
tags: |
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 }}
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
+2 -3
View File
@@ -1,6 +1,5 @@
ARG APP_PATH=/opt/outline
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
FROM outlinewiki/outline-base AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -31,7 +30,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" && \
+1 -4
View File
@@ -1,14 +1,11 @@
ARG APP_PATH=/opt/outline
FROM node:20 AS deps
FROM node:20-slim 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
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.83.0
Licensed Work: Outline 0.82.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-04-11
Change Date: 2029-02-15
Change License: Apache License, Version 2.0
+1 -27
View File
@@ -29,7 +29,6 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -511,25 +510,6 @@ 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,
@@ -575,12 +555,7 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
});
export const duplicateDocument = createAction({
@@ -1230,7 +1205,6 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
+2 -1
View File
@@ -5,6 +5,7 @@ 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 = {
@@ -32,7 +33,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to="/" />;
return <Redirect to={logoutPath()} />;
};
export default observer(Authenticated);
+1
View File
@@ -69,6 +69,7 @@ function CollectionDescription({ collection }: Props) {
readOnly={!can.update}
userId={user.id}
editorStyle={editorStyle}
embedsDisabled
/>
<div ref={childRef} />
</React.Suspense>
+141 -147
View File
@@ -1,5 +1,4 @@
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";
@@ -9,7 +8,6 @@ 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";
@@ -25,9 +23,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -38,155 +36,151 @@ enum Direction {
DOWN,
}
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;
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 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 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 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);
}
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
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>
);
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
);
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
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) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -196,7 +190,7 @@ function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
unfurlId={unfurlId}
data={data}
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 {
+1 -6
View File
@@ -45,10 +45,6 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -130,14 +126,13 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
type?: "text" | "email" | "checkbox" | "search" | "textarea";
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 */
+26
View File
@@ -0,0 +1,26 @@
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);
-3
View File
@@ -114,8 +114,6 @@ const Modal: React.FC<Props> = ({
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
>
@@ -261,7 +259,6 @@ const Small = styled.div`
width: 75vw;
min-width: 350px;
max-width: 450px;
max-height: 65vh;
z-index: ${depths.modal};
display: flex;
justify-content: center;
+6 -20
View File
@@ -54,8 +54,6 @@ 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
@@ -116,10 +114,6 @@ 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);
}
@@ -128,20 +122,12 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
// 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);
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}
},
[width, hasPointerMoved]
@@ -148,12 +148,7 @@ function InnerDocumentLink(
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
document,
isEditing
);
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -275,8 +270,6 @@ function InnerDocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
@@ -292,7 +285,6 @@ function InnerDocumentLink(
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
+6 -12
View File
@@ -39,7 +39,6 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
location?: Location;
strict?: boolean;
to: LocationDescriptor;
component?: React.ComponentType;
onBeforeClick?: () => void;
}
@@ -147,22 +146,17 @@ const NavLink = ({
setPreActive(undefined);
}, [currentLocation]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
},
[navigateTo]
);
return (
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
onKeyDown={handleKeyDown}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -166,13 +166,11 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document,
isEditing?: boolean
document?: Document
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -190,7 +188,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive && !isEditing,
canDrag: () => !!document?.isActive,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
+1 -3
View File
@@ -335,7 +335,6 @@ const TR = styled.div<{ $columns: string }>`
grid-template-columns: ${({ $columns }) => `${$columns}`};
align-items: center;
border-bottom: 1px solid ${s("divider")};
overflow: hidden;
&:last-child {
border-bottom: 0;
@@ -358,8 +357,7 @@ const TD = styled.span`
padding: 10px 6px;
font-size: 14px;
text-wrap: wrap;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
&:first-child {
font-size: 15px;
+71 -1
View File
@@ -1,3 +1,73 @@
import Text from "@shared/components/Text";
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()}
`;
export default Text;
+24 -59
View File
@@ -1,15 +1,7 @@
import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import { 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";
@@ -23,65 +15,34 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const PasteMenu = ({ embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(pastedText);
const matches = e.matcher(props.pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, pastedText]);
}, [embeds, props.pastedText]);
const items = React.useMemo(
() =>
[
{
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]
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
@@ -91,7 +52,9 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={options.onClick}
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -100,4 +63,6 @@ export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
items={items}
/>
);
});
};
export default PasteMenu;
+1 -1
View File
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
if ((readOnly && !canComment) || isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
@@ -1,6 +1,5 @@
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";
/**
@@ -19,25 +18,17 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice, view) => {
clipboardTextSerializer: (slice) => {
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 || hasMultipleBlockTypes || hasMultipleListItems;
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
+13 -20
View File
@@ -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;
unfurlId: string | null;
data: Record<string, any> | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
unfurlId: null,
data: null,
dataLoading: false,
});
@@ -62,25 +62,19 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
this.state.activeLinkElement = element;
this.state.unfurlId = transformedUrl;
} else {
this.state.data = data;
} catch (err) {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -107,11 +101,10 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
unfurlId={this.state.unfurlId}
data={this.state.data}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+1 -21
View File
@@ -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,21 +415,6 @@ 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;
@@ -465,11 +450,6 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
+18 -2
View File
@@ -1,3 +1,19 @@
import useIsMounted from "@shared/hooks/useIsMounted";
import * as React from "react";
export default 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, []);
}
+10
View File
@@ -8,6 +8,7 @@ import {
GlobeIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -39,6 +40,7 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
@@ -175,6 +177,14 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update && !isCloudHosted,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
-19
View File
@@ -17,7 +17,6 @@ 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";
@@ -664,24 +663,6 @@ 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`,
+3 -3
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import {
import type {
IntegrationService,
type IntegrationSettings,
type IntegrationType,
IntegrationSettings,
IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
-18
View File
@@ -1,18 +0,0 @@
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;
-2
View File
@@ -209,9 +209,7 @@ function Invite({ onSubmit }: Props) {
placeholder={`name@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoComplete="off"
autoFocus
data-1p-ignore
flex
/>
<StyledInput
+1 -4
View File
@@ -39,10 +39,7 @@ export function LoginDialog() {
maxLength={255}
autoComplete="off"
placeholder={t("subdomain")}
{...register("subdomain", {
required: true,
pattern: /^[a-z\d-]{1,63}$/,
})}
{...register("subdomain", { required: true, pattern: /^[a-z\d-]+$/ })}
>
<Domain>.getoutline.com</Domain>
</Input>
+1 -10
View File
@@ -3,15 +3,6 @@ import { parseDomain } from "@shared/utils/domains";
import env from "~/env";
import Desktop from "~/utils/Desktop";
function validateAndEncodeSubdomain(subdomain: string): string {
const encodedSubdomain = encodeURIComponent(subdomain);
const urlPattern = /^[a-z\d-]{1,63}$/;
if (!urlPattern.test(encodedSubdomain)) {
throw new Error("Invalid subdomain");
}
return `https://${encodedSubdomain}.getoutline.com`;
}
/**
* If we're on a custom domain or a subdomain then the auth must point to the
* apex (env.URL) for authentication so that the state cookie can be set and read.
@@ -45,7 +36,7 @@ export async function navigateToSubdomain(subdomain: string) {
.toLowerCase()
.trim()
.replace(/^https?:\/\//, "");
const host = validateAndEncodeSubdomain(normalizedSubdomain);
const host = `https://${normalizedSubdomain}.getoutline.com`;
await Desktop.bridge?.addCustomHost(host);
window.location.href = host;
}
+140
View File
@@ -0,0 +1,140 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
gristUrl: string;
};
function SelfHosted() {
const { integrations } = useStores();
const { t } = useTranslation();
const integrationDiagrams = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const integrationGrist = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Grist,
}) as Integration<IntegrationType.Embed> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Embed,
});
}, [integrations]);
React.useEffect(() => {
reset({
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
});
}, [integrationDiagrams, integrationGrist, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.drawIoUrl) {
await integrations.save({
id: integrationDiagrams?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integrationDiagrams?.delete();
}
if (data.gristUrl) {
await integrations.save({
id: integrationGrist?.id,
type: IntegrationType.Embed,
service: IntegrationService.Grist,
settings: {
url: data.gristUrl,
},
});
} else {
await integrationGrist?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integrationDiagrams, integrationGrist, t]
);
return (
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
<Heading>{t("Self Hosted")}</Heading>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<SettingRow
label={t("Grist deployment")}
name="gristUrl"
description={t("Add your self-hosted grist installation URL here.")}
border={false}
>
<Input
placeholder="https://docs.getgrist.com/"
pattern="https?://.*"
{...register("gristUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(SelfHosted);
@@ -4,7 +4,6 @@ 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,
@@ -14,6 +13,7 @@ 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";
+123 -153
View File
@@ -1,18 +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 { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import Dropzone from "react-dropzone";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import ButtonLarge from "~/components/ButtonLarge";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import useStores from "~/hooks/useStores";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -23,167 +24,132 @@ export type Props = {
borderRadius?: number;
};
const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius,
children,
}) => {
const { dialogs } = useStores();
const { t } = useTranslation();
@observer
class ImageUpload extends React.Component<RootStore & Props> {
@observable
isUploading = false;
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
@observable
isCropping = false;
const uploadImage = React.useCallback(
async (blob: Blob, file: File) => {
try {
const compressed = await compressImage(blob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
void onSuccess(attachment.url);
} catch (err) {
onError(err.message);
} finally {
setIsUploading(false);
setIsCropping(false);
dialogs.closeAllModals();
}
},
[dialogs, onSuccess, onError]
);
@observable
zoom = 1;
const handleUpload = React.useCallback(
(blob: Blob, file: File) => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(() => uploadImage(blob, file), 0);
},
[uploadImage]
);
@observable
file: File;
const handleClose = React.useCallback(() => {
setIsUploading(false);
setIsCropping(false);
}, []);
avatarEditorRef = React.createRef<AvatarEditor>();
const onDropAccepted = React.useCallback(
async (files: File[]) => {
setIsCropping(true);
dialogs.openModal({
title: "",
content: (
<AvatarEditorDialog
file={files[0]}
onUpload={handleUpload}
isUploading={isUploading}
borderRadius={borderRadius ?? 150}
submitText={submitText || t("Crop image")}
/>
),
onClose: handleClose,
static defaultProps = {
submitText: "Crop Image",
borderRadius: 150,
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = () => {
this.isUploading = true;
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(this.uploadImage, 0);
};
uploadImage = async () => {
const canvas = this.avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
const compressed = await compressImage(imageBlob, {
maxHeight: 512,
maxWidth: 512,
});
},
[
t,
dialogs,
handleUpload,
handleClose,
isUploading,
borderRadius,
submitText,
]
);
const attachment = await uploadFile(compressed, {
name: this.file.name,
preset: AttachmentPreset.Avatar,
});
void this.props.onSuccess(attachment.url);
} catch (err) {
this.props.onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
}
};
const { getRootProps, getInputProps } = useDropzone({
accept: AttachmentValidation.avatarContentTypes.join(", "),
onDropAccepted,
});
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
};
if (isCropping) {
return null; // onDropAccepted would have opened a modal for cropping the image.
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
}
};
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal isOpen onRequestClose={this.handleClose} title="">
<Flex auto column align="center" justify="center">
{this.isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={this.avatarEditorRef}
image={this.file}
width={250}
height={250}
border={25}
borderRadius={this.props.borderRadius}
color={
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
} // RGBA
scale={this.zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
/>
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
{this.isUploading ? "Uploading…" : submitText}
</CropButton>
</Flex>
</Modal>
);
}
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
);
};
type AvatarEditorDialogProps = {
file: File;
onUpload: (blob: Blob, file: File) => void;
isUploading: boolean;
borderRadius: number;
submitText: string;
};
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
({ file, onUpload, isUploading, borderRadius, submitText }) => {
const { ui } = useStores();
const { t } = useTranslation();
const [zoom, setZoom] = useState(1);
const avatarEditorRef = useRef<AvatarEditor>(null);
const handleUpload = React.useCallback(() => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const blob = dataUrlToBlob(canvas.toDataURL());
onUpload(blob, file);
}, [file, onUpload]);
const handleZoom = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
setZoom(parseFloat(target.value));
}
},
[]
);
render() {
if (this.isCropping) {
return this.renderCropping();
}
return (
<Flex auto column align="center" justify="center">
{isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={avatarEditorRef}
image={file}
width={250}
height={250}
border={25}
borderRadius={borderRadius}
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
scale={zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{this.props.children}
</div>
)}
</Dropzone>
);
}
);
}
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -214,4 +180,8 @@ const RangeInput = styled.input`
}
`;
export default observer(ImageUpload);
const CropButton = styled(Button)`
width: 300px;
`;
export default withStores(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";
+16 -28
View File
@@ -2,7 +2,6 @@ import compact from "lodash/compact";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Text from "@shared/components/Text";
import User from "~/models/User";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Badge from "~/components/Badge";
@@ -15,7 +14,6 @@ import {
import { type Column as TableColumn } from "~/components/Table";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useMobile from "~/hooks/useMobile";
import UserMenu from "~/menus/UserMenu";
import { FILTER_HEIGHT } from "./StickyFilters";
@@ -29,7 +27,6 @@ type Props = Omit<TableProps<User>, "columns" | "rowHeight"> & {
export function MembersTable({ canManage, ...rest }: Props) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const isMobile = useMobile();
const columns = React.useMemo<TableColumn<User>[]>(
() =>
@@ -41,20 +38,13 @@ export function MembersTable({ canManage, ...rest }: Props) {
accessor: (user) => user.name,
component: (user) => (
<Flex align="center" gap={8}>
<Avatar model={user} size={AvatarSize.Large} />{" "}
<Flex column>
<Text>
{user.name} {currentUser.id === user.id && `(${t("You")})`}
</Text>
{isMobile && canManage && (
<Text type="tertiary">{user.email}</Text>
)}
</Flex>
<Avatar model={user} size={AvatarSize.Large} /> {user.name}{" "}
{currentUser.id === user.id && `(${t("You")})`}
</Flex>
),
width: "4fr",
},
canManage && !isMobile
canManage
? {
type: "data",
id: "email",
@@ -64,19 +54,17 @@ export function MembersTable({ canManage, ...rest }: Props) {
width: "4fr",
}
: undefined,
isMobile
? undefined
: {
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "lastActiveAt",
header: t("Last active"),
accessor: (user) => user.lastActiveAt,
component: (user) =>
user.lastActiveAt ? (
<Time dateTime={user.lastActiveAt} addSuffix />
) : null,
width: "2fr",
},
{
type: "data",
id: "role",
@@ -97,7 +85,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</Badges>
),
width: "80px",
width: "2fr",
},
canManage
? {
@@ -109,7 +97,7 @@ export function MembersTable({ canManage, ...rest }: Props) {
}
: undefined,
]),
[t, currentUser, canManage, isMobile]
[t, currentUser, canManage]
);
return (
+1 -1
View File
@@ -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 + window.location.search);
setPostLoginPath(window.location.pathname);
}
if (tryRevokingToken) {
-3
View File
@@ -26,7 +26,6 @@ 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";
@@ -56,7 +55,6 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -87,7 +85,6 @@ 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);
-85
View File
@@ -1,85 +0,0 @@
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 -9
View File
@@ -1,6 +1,5 @@
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";
@@ -99,14 +98,7 @@ export default abstract class Store<T extends Model> {
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
if (!normalized) {
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
return this.orderedData.slice(0, options?.maxResults);
}
return this.orderedData
-58
View File
@@ -1,58 +0,0 @@
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;
}
};
+3 -5
View File
@@ -49,10 +49,8 @@ export function redirectTo(url: string) {
/**
* Check if the path is a valid path for redirect after login.
*
* @param input A path potentially including query string
* @param path
* @returns boolean indicating if the path is a valid redirect
*/
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0];
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
};
export const isAllowedLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
+23 -24
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.0",
"@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",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -79,12 +79,12 @@
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@notionhq/client": "^2.3.0",
"@notionhq/client": "^2.2.16",
"@octokit/auth-app": "^6.1.3",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-visually-hidden": "^1.2.0",
"@radix-ui/react-visually-hidden": "^1.1.2",
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
@@ -114,7 +114,7 @@
"date-fns": "^3.6.0",
"dd-trace": "^5.40.0",
"diff": "^5.2.0",
"dotenv": "^16.5.0",
"dotenv": "^16.4.7",
"email-providers": "^1.14.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
@@ -141,7 +141,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.16.1",
"koa": "^2.15.4",
"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.9.0",
"mammoth": "^1.8.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
@@ -179,19 +179,19 @@
"png-chunks-extract": "^1.0.0",
"polished": "^4.3.1",
"prosemirror-codemark": "^0.4.2",
"prosemirror-commands": "^1.7.1",
"prosemirror-commands": "^1.7.0",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.0",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.39.1",
"prosemirror-view": "^1.38.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -210,7 +210,7 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.26",
"react-virtualized-auto-sizer": "^1.0.21",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
@@ -219,7 +219,7 @@
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"resolve-path": "^1.4.0",
"rfc6902": "^5.1.2",
"rfc6902": "^5.1.1",
"sanitize-filename": "^1.6.3",
"scroll-into-view-if-needed": "^3.1.0",
"semver": "^7.7.1",
@@ -232,7 +232,7 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.4",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
@@ -248,8 +248,8 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.21.2",
"vite": "^5.4.16",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
"y-indexeddb": "^9.0.11",
@@ -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.30",
"@types/node": "20.17.27",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -362,7 +362,7 @@
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"typescript": "^5.8.3",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -377,6 +377,5 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.83.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
"version": "0.82.0"
}
+1 -1
View File
@@ -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;
let teamName = "Wiki";
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
let teamAvatarUrl: string | undefined = undefined;
let subdomain = slugifyDomain(domain);
@@ -1,40 +0,0 @@
import { Endpoints } from "@octokit/types";
import { IssueSource } from "@shared/schema";
import { IntegrationService, IntegrationType } from "@shared/types";
import { Integration } from "@server/models";
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
import { GitHub } from "./github";
// This is needed to handle 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 sources: IssueSource[] = [];
for await (const response of client.requestRepos()) {
const repos = response.data as unknown as ReposForInstallation;
sources.push(
...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;
}
}
+20 -18
View File
@@ -2,7 +2,6 @@ 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";
@@ -89,27 +88,30 @@ router.get(
},
{ transaction }
);
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,
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,
},
},
},
},
},
});
{ transaction }
);
ctx.redirect(GitHubUtils.url);
}
);
+23 -94
View File
@@ -4,55 +4,36 @@ import {
type OAuthWebFlowAuthOptions,
type InstallationAuthOptions,
} from "@octokit/auth-app";
import { Endpoints, OctokitResponse } from "@octokit/types";
import { Octokit } from "octokit";
import pluralize from "pluralize";
import {
IntegrationService,
IntegrationType,
JSONObject,
UnfurlResourceType,
} from "@shared/types";
import Logger from "@server/logging/Logger";
import { Integration, User } from "@server/models";
import { UnfurlIssueAndPR, UnfurlSignature } from "@server/types";
import { GitHubUtils } from "../shared/GitHubUtils";
import { UnfurlSignature } from "@server/types";
import env from "./env";
type PR =
Endpoints["GET /repos/{owner}/{repo}/pulls/{pull_number}"]["response"]["data"];
type Issue =
Endpoints["GET /repos/{owner}/{repo}/issues/{issue_number}"]["response"]["data"];
const requestPlugin = (octokit: Octokit) => ({
requestRepos: () =>
octokit.paginate.iterator(
octokit.rest.apps.listReposAccessibleToInstallation,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}
),
requestPR: async (params: NonNullable<ReturnType<typeof GitHub.parseUrl>>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{pull_number}`, {
owner: params.owner,
repo: params.repo,
pull_number: params.id,
requestPR: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/pulls/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
},
}),
requestIssue: async (
params: NonNullable<ReturnType<typeof GitHub.parseUrl>>
) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{issue_number}`, {
owner: params.owner,
repo: params.repo,
issue_number: params.id,
requestIssue: async (params: ReturnType<typeof GitHub.parseUrl>) =>
octokit.request(`GET /repos/{owner}/{repo}/issues/{id}`, {
owner: params?.owner,
repo: params?.repo,
id: params?.id,
headers: {
Accept: "application/vnd.github.text+json",
"X-GitHub-Api-Version": "2022-11-28",
@@ -75,14 +56,14 @@ const requestPlugin = (octokit: Octokit) => ({
*/
requestResource: async function requestResource(
resource: ReturnType<typeof GitHub.parseUrl>
): Promise<OctokitResponse<Issue | PR> | undefined> {
): Promise<{ data?: JSONObject }> {
switch (resource?.type) {
case UnfurlResourceType.PR:
return this.requestPR(resource) as Promise<OctokitResponse<PR>>;
return this.requestPR(resource);
case UnfurlResourceType.Issue:
return this.requestIssue(resource) as Promise<OctokitResponse<Issue>>;
return this.requestIssue(resource);
default:
return;
return { data: undefined };
}
},
@@ -110,10 +91,7 @@ export class GitHub {
private static appOctokit: Octokit;
private static supportedResources = [
UnfurlResourceType.Issue,
UnfurlResourceType.PR,
];
private static supportedResources = Object.values(UnfurlResourceType);
/**
* Parses a given URL and returns resource identifiers for GitHub specific URLs
@@ -133,7 +111,7 @@ export class GitHub {
const type = parts[3]
? (pluralize.singular(parts[3]) as UnfurlResourceType)
: undefined;
const id = Number(parts[4]);
const id = parts[4];
if (!type || !GitHub.supportedResources.includes(type)) {
return;
@@ -226,63 +204,14 @@ export class GitHub {
const client = await GitHub.authenticateAsInstallation(
integration.settings.github!.installation.id
);
const res = await client.requestResource(resource);
if (!res) {
return { error: "Resource not found" };
const { data } = await client.requestResource(resource);
if (!data) {
return;
}
return GitHub.transformData(res.data, resource.type);
return { ...data, type: resource.type };
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return { error: err.message || "Unknown error" };
return;
}
};
private static transformData(data: Issue | PR, type: UnfurlResourceType) {
if (type === UnfurlResourceType.Issue) {
const issue = data as Issue;
return {
type: UnfurlResourceType.Issue,
url: issue.html_url,
id: `#${issue.number}`,
title: issue.title,
description: issue.body_text ?? null,
author: {
name: issue.user?.login ?? "",
avatarUrl: issue.user?.avatar_url ?? "",
},
labels: issue.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: issue.state,
color: GitHubUtils.getColorForStatus(issue.state),
},
createdAt: issue.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
const pr = data as PR;
const prState = pr.merged ? "merged" : pr.state;
return {
type: UnfurlResourceType.PR,
url: pr.html_url,
id: `#${pr.number}`,
title: pr.title,
description: pr.body,
author: {
name: pr.user.login,
avatarUrl: pr.user.avatar_url,
},
state: {
name: prState,
color: GitHubUtils.getColorForStatus(prState),
},
createdAt: pr.created_at,
transformed_unfurl: true,
} satisfies UnfurlIssueAndPR;
}
}
-5
View File
@@ -1,7 +1,6 @@
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";
@@ -21,10 +20,6 @@ if (enabled) {
type: Hook.API,
value: router,
},
{
type: Hook.IssueProvider,
value: new GitHubIssueProvider(),
},
{
type: Hook.UnfurlProvider,
value: { unfurl: GitHub.unfurl, cacheExpiry: Minute.seconds },
+4 -6
View File
@@ -1,6 +1,6 @@
import { JSONObject, UnfurlResourceType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { UnfurlError, UnfurlSignature } from "@server/types";
import { UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
@@ -10,7 +10,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "oembed"
): Promise<JSONObject | UnfurlError> {
): Promise<JSONObject | undefined> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
// Cloud Iframely requires /api path, while self-hosted does not.
@@ -25,7 +25,7 @@ class Iframely {
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return { error: err.message || "Unknown error" };
return;
}
}
@@ -36,9 +36,7 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.OEmbed };
return { ...data, type: UnfurlResourceType.OEmbed };
};
}
+1 -12
View File
@@ -13,11 +13,9 @@ import {
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import emojiRegex from "emoji-regex";
import compact from "lodash/compact";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { isUrl } from "@shared/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
@@ -39,16 +37,7 @@ const AccessTokenResponseSchema = z.object({
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z
.string()
.nullish()
.transform((val) => {
const emojiRegexp = emojiRegex();
if (val && (isUrl(val) || emojiRegexp.test(val))) {
return val;
}
return undefined;
}),
workspace_icon: z.string().url().nullish(),
});
export class NotionClient {
-29
View File
@@ -277,35 +277,6 @@ describe("#files.get", () => {
);
});
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
const attachment = await buildAttachment({
key,
teamId: user.teamId,
userId: user.id,
contentType: "image/jpg",
acl: "public-read",
});
await attachment.destroy({
hooks: false,
});
ensureDirSync(
path.dirname(path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key))
);
copyFileSync(
path.resolve(__dirname, "..", "test", "fixtures", "avatar.jpg"),
path.join(env.FILE_STORAGE_LOCAL_ROOT_DIR, key)
);
const res = await server.get(`/api/files.get?key=${key}`);
expect(res.status).toEqual(200);
expect(res.headers.get("Content-Disposition")).toEqual("attachment");
});
it("should succeed with status 200 ok when avatar is requested using key", async () => {
const user = await buildUser();
const key = path.join("avatars", user.id, uuidV4());
+9 -6
View File
@@ -78,13 +78,17 @@ router.get(
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
const attachment = await Attachment.findByKey(key);
const attachment = await Attachment.findOne({
where: { key },
});
// Attachment is requested with a key, but it was not found
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
if (!skipAuthorize) {
if (!attachment && !!ctx.input.query.key) {
throw NotFoundError();
}
authorize(actor, "read", attachment);
}
@@ -96,7 +100,6 @@ router.get(
ctx.set("Accept-Ranges", "bytes");
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
ctx.set("Content-Security-Policy", "sandbox");
ctx.attachment(fileName, {
type: forceDownload
? "attachment"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -264,6 +264,7 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: faker.internet.domainWord(),
},
@@ -307,6 +308,7 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: faker.internet.domainWord(),
},
+1 -2
View File
@@ -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,7 +92,6 @@ async function accountProvisioner({
try {
result = await teamProvisioner({
name: "Wiki",
...teamParams,
authenticationProvider: authenticationProviderParams,
ip,
+1 -1
View File
@@ -192,7 +192,7 @@ describe("userProvisioner", () => {
it("should prefer isAdmin argument over defaultUserRole", async () => {
const team = await buildTeam({
defaultUserRole: UserRole.Viewer,
defaultUserRole: "viewer",
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
@@ -86,8 +86,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
}
return;
},
30,
10000
30
);
}
@@ -1,15 +0,0 @@
"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");
},
};
-15
View File
@@ -5,7 +5,6 @@ import {
InferAttributes,
InferCreationAttributes,
QueryTypes,
FindOptions,
} from "sequelize";
import {
BeforeDestroy,
@@ -165,20 +164,6 @@ class Attachment extends IdModel<
// static methods
/**
* Find an attachment by its key.
*
* @param key - The key of the attachment to find.
* @param options - Additional options for the query.
* @returns A promise resolving to the attachment with the given key, or null if not found.
*/
static async findByKey(
key: string,
options?: FindOptions<Attachment>
): Promise<Attachment | null> {
return this.findOne({ where: { key }, ...options });
}
/**
* Get the total size of all attachments for a given team.
*
-29
View File
@@ -11,7 +11,6 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -79,34 +78,6 @@ 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();
+5 -13
View File
@@ -1112,26 +1112,18 @@ class Document extends ArchivableModel<
// Delete a document, archived or otherwise.
delete = (user: User) =>
this.sequelize.transaction(async (transaction: Transaction) => {
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
if (!this.archivedAt && !this.template && this.collectionId) {
// delete any children and remove from the document structure
const collection = await Collection.findByPk(this.collectionId, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
});
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
await collection?.deleteDocument(this, { transaction });
deleted = true;
}
}
if (!deleted) {
await collection?.deleteDocument(this, { transaction });
} else {
await this.destroy({
transaction,
});
deleted = true;
}
this.lastModifiedById = user.id;
-4
View File
@@ -13,7 +13,6 @@ 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";
@@ -54,9 +53,6 @@ class Integration<T = unknown> extends ParanoidModel<
@Column(DataType.ARRAY(DataType.STRING))
events: string[];
@Column(DataType.JSONB)
issueSources: IssueSource[] | null;
// associations
@BelongsTo(() => User, "userId")
@@ -251,10 +251,6 @@ describe("NotificationHelper", () => {
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
+7 -9
View File
@@ -1,5 +1,4 @@
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import {
NotificationEventType,
@@ -188,6 +187,7 @@ export default class NotificationHelper {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: {
[Op.ne]: actorId,
@@ -206,19 +206,17 @@ export default class NotificationHelper {
],
});
recipients = uniqBy(
subscriptions.map((s) => s.user),
(user) => user.id
);
recipients = subscriptions.map((s) => s.user);
}
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
const filtered = [];
for (const recipient of recipients) {
if (
recipient.isSuspended ||
!recipient.subscribedToEventType(notificationType)
) {
if (recipient.isSuspended) {
continue;
}
+46 -7
View File
@@ -1,18 +1,19 @@
import { JSDOM } from "jsdom";
import compact from "lodash/compact";
import flatten from "lodash/flatten";
import isMatch from "lodash/isMatch";
import isEqual from "lodash/isEqual";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
import { Node, DOMSerializer, Fragment, Mark } 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, UnfurlResponse } from "@shared/types";
import { MentionType, ProsemirrorData } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
@@ -42,8 +43,6 @@ export type MentionAttrs = {
modelId: string;
actorId: string | undefined;
id: string;
href?: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
};
@trace()
@@ -62,7 +61,47 @@ export class ProsemirrorHelper {
);
}
const node = parser.parse(input);
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);
}
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
}
@@ -196,7 +235,7 @@ export class ProsemirrorHelper {
node.descendants((childNode: Node) => {
if (
childNode.type.name === "mention" &&
isMatch(childNode.attrs, mention)
isEqual(childNode.attrs, mention)
) {
foundMention = true;
return false;
+11 -2
View File
@@ -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 } from "./cancan";
import { allow, can } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
@@ -67,6 +67,15 @@ allow(
}
);
allow(User, "export", Collection, (actor, collection) =>
and(
//
can(actor, "read", collection),
!actor.isViewer,
!actor.isGuest
)
);
allow(User, "share", Collection, (user, collection) => {
if (
!collection ||
@@ -152,7 +161,7 @@ allow(
}
);
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
allow(User, ["update", "archive"], Collection, (user, collection) =>
and(
!!collection,
!!collection?.isActive,
+38 -54
View File
@@ -71,63 +71,47 @@ const presentDocument = (
const presentPR = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.PR] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformed_unfurl) {
delete data.transformed_unfurl;
return data as UnfurlResponse[UnfurlResourceType.PR]; // this would have been transformed by the unfurl plugin.
}
return {
url: data.html_url,
type: UnfurlResourceType.PR,
id: `#${data.number}`,
title: data.title,
description: data.body,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
state: {
name: data.merged ? "merged" : data.state,
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
},
createdAt: data.created_at,
};
};
): UnfurlResponse[UnfurlResourceType.PR] => ({
url: data.html_url,
type: UnfurlResourceType.PR,
id: `#${data.number}`,
title: data.title,
description: data.body,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
state: {
name: data.merged ? "merged" : data.state,
color: GitHubUtils.getColorForStatus(data.merged ? "merged" : data.state),
},
createdAt: data.created_at,
});
const presentIssue = (
data: Record<string, any>
): UnfurlResponse[UnfurlResourceType.Issue] => {
// TODO: For backwards compatibility, remove once cache has expired in next release.
if (data.transformed_unfurl) {
delete data.transformed_unfurl;
return data as UnfurlResponse[UnfurlResourceType.Issue]; // this would have been transformed by the unfurl plugin.
}
return {
url: data.html_url,
type: UnfurlResourceType.Issue,
id: `#${data.number}`,
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: data.state,
color: GitHubUtils.getColorForStatus(
data.state === "closed" ? "done" : data.state
),
},
createdAt: data.created_at,
};
};
): UnfurlResponse[UnfurlResourceType.Issue] => ({
url: data.html_url,
type: UnfurlResourceType.Issue,
id: `#${data.number}`,
title: data.title,
description: data.body_text,
author: {
name: data.user.login,
avatarUrl: data.user.avatar_url,
},
labels: data.labels.map((label: { name: string; color: string }) => ({
name: label.name,
color: `#${label.color}`,
})),
state: {
name: data.state,
color: GitHubUtils.getColorForStatus(
data.state === "closed" ? "done" : data.state
),
},
createdAt: data.created_at,
});
const presentLastOnlineInfoFor = (user: User) => {
const locale = dateLocale(user.language);
@@ -3,7 +3,6 @@ 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"];
@@ -19,11 +18,6 @@ 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));
}
@@ -1,32 +0,0 @@
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) {
const integration = await Integration.findByPk(integrationId);
if (!integration) {
return;
}
const plugin = plugins.find((p) => p.value.service === integration.service);
if (!plugin) {
return;
}
const sources = await plugin.value.fetchSources(integration);
await sequelize.transaction(async (transaction) => {
await integration.reload({ transaction, lock: transaction.LOCK.UPDATE });
integration.issueSources = sources;
await integration.save({ transaction });
});
}
}
@@ -85,21 +85,16 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
await sequelize.transaction(async (transaction) => {
for (const recipient of recipients) {
await Notification.create(
{
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
},
{ transaction }
);
}
});
for (const recipient of recipients) {
await Notification.create({
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
});
}
}
public get options() {
+1 -1
View File
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
jest
.spyOn(Iframely, "requestResource")
.mockImplementation(() => Promise.resolve({}));
.mockImplementation(() => Promise.resolve(undefined));
const server = getTestServer();
+3 -4
View File
@@ -98,12 +98,11 @@ router.post(
}
for (const plugin of plugins) {
const unfurl = await plugin.value.unfurl(url, actor);
if (unfurl) {
if ("error" in unfurl) {
const data = await plugin.value.unfurl(url, actor);
if (data) {
if ("error" in data) {
return (ctx.response.status = 204);
} else {
const data = unfurl as Unfurl;
await CacheHelper.setData(
CacheHelper.getUnfurlKey(actor.teamId, url),
data,
+2 -18
View File
@@ -10,7 +10,6 @@ import {
JSONValue,
UnfurlResourceType,
ProsemirrorData,
UnfurlResponse,
} from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
import { AccountProvisionerResult } from "./commands/accountProvisioner";
@@ -577,27 +576,12 @@ export type CollectionJSONExport = {
};
};
export type UnfurlIssueAndPR = (
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR]
) & { transformed_unfurl: true };
export type Unfurl =
| UnfurlIssueAndPR
| {
type: Exclude<
UnfurlResourceType,
UnfurlResourceType.Issue | UnfurlResourceType.PR
>;
[x: string]: JSONValue;
};
export type UnfurlError = { error: string };
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
export type UnfurlSignature = (
url: string,
actor?: User
) => Promise<Unfurl | UnfurlError | undefined>;
) => Promise<Unfurl | void>;
export type UninstallSignature = (integration: Integration) => Promise<void>;
-15
View File
@@ -1,15 +0,0 @@
import { IssueSource } from "@shared/schema";
import { IntegrationType, IssueTrackerIntegrationService } from "@shared/types";
import { Integration } from "@server/models";
export abstract class BaseIssueProvider {
service: IssueTrackerIntegrationService;
constructor(service: IssueTrackerIntegrationService) {
this.service = service;
}
abstract fetchSources(
integration: Integration<IntegrationType.Embed>
): Promise<IssueSource[]>;
}
+5 -5
View File
@@ -18,14 +18,11 @@ 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,
lockTimeout: number = MutexLock.defaultLockTimeout
expiry?: number
): Promise<T | undefined> {
let cache = await this.getData<T>(key);
@@ -38,7 +35,10 @@ export class CacheHelper {
const lockKey = `lock:${key}`;
try {
try {
lock = await MutexLock.lock.acquire([lockKey], lockTimeout);
lock = await MutexLock.lock.acquire(
[lockKey],
MutexLock.defaultLockTimeout
);
} catch (err) {
Logger.error(`Could not acquire lock for ${key}`, err);
}
-3
View File
@@ -9,7 +9,6 @@ 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 "./BaseIssueProvider";
export enum PluginPriority {
VeryHigh = 0,
@@ -26,7 +25,6 @@ export enum Hook {
API = "api",
AuthProvider = "authProvider",
EmailTemplate = "emailTemplate",
IssueProvider = "issueProvider",
Processor = "processor",
Task = "task",
UnfurlProvider = "unfurl",
@@ -41,7 +39,6 @@ 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;
-87
View File
@@ -1,87 +0,0 @@
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");
});
});
+5 -5
View File
@@ -10,8 +10,10 @@ 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", "teamId"],
attributes: ["id", "index", "name"],
transaction,
});
@@ -24,9 +26,7 @@ export async function collectionIndexing(
for (const collection of sortable) {
if (collection.index === null) {
collection.index = fractionalIndex(previousIndex, null);
promises.push(
collection.save({ fields: ["index"], silent: true, transaction })
);
promises.push(collection.save({ fields: ["index"], transaction })); // save only index to prevent overwriting other unfetched fields.
}
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({ silent: true }));
promises.push(star.save());
}
previousIndex = star.index;
-73
View File
@@ -1,73 +0,0 @@
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;
+2 -2
View File
@@ -17,10 +17,10 @@ type Props = ComponentProps & {
const Embed = (props: Props) => {
const ref = React.useRef<HTMLIFrameElement>(null);
const { node, isEditable, embedsDisabled, onChangeSize } = props;
const { node, isEditable, onChangeSize } = props;
const naturalWidth = 0;
const naturalHeight = 400;
const isResizable = !!onChangeSize && !embedsDisabled;
const isResizable = !!onChangeSize;
const { width, height, setSize, handlePointerDown, dragging } = useDragResize(
{
+77 -73
View File
@@ -1,15 +1,13 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import { OpenIcon } from "outline-icons";
import * as React from "react";
import { useState, useEffect, useRef } from "react";
import styled from "styled-components";
import { Optional } from "utility-types";
import { s } from "../../styles";
import { sanitizeUrl } from "../../utils/urls";
type Props = Omit<
Optional<React.ComponentProps<typeof Iframe>>,
"children" | "style"
> & {
type Props = Omit<Optional<HTMLIFrameElement>, "children" | "style"> & {
/** The URL to load in the iframe */
src?: string;
/** Whether to display a border, defaults to true */
@@ -32,79 +30,85 @@ type PropsWithRef = Props & {
forwardedRef: React.Ref<HTMLIFrameElement>;
};
const Frame = ({
border,
style = {},
forwardedRef,
icon,
title,
canonicalUrl,
isSelected,
referrerPolicy,
className = "",
src,
...rest
}: PropsWithRef) => {
const [isLoaded, setIsLoaded] = useState(false);
const mountedRef = useRef(true);
@observer
class Frame extends React.Component<PropsWithRef> {
mounted: boolean;
useEffect(() => {
// Set mounted flag
mountedRef.current = true;
@observable
isLoaded = false;
// Load iframe after a small delay
const timer = setTimeout(() => {
if (mountedRef.current) {
setIsLoaded(true);
}
}, 0);
componentDidMount() {
this.mounted = true;
setTimeout(this.loadIframe, 0);
}
// Cleanup function
return () => {
mountedRef.current = false;
clearTimeout(timer);
};
}, []);
componentWillUnmount() {
this.mounted = false;
}
const showBottomBar = !!(icon || canonicalUrl);
loadIframe = () => {
if (!this.mounted) {
return;
}
this.isLoaded = true;
};
return (
<Rounded
style={style}
$showBottomBar={showBottomBar}
$border={border}
className={
isSelected ? `ProseMirror-selectednode ${className}` : className
}
>
{isLoaded && (
<Iframe
ref={forwardedRef}
$showBottomBar={showBottomBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-storage-access-by-user-activation"
style={style}
frameBorder="0"
title="embed"
loading="lazy"
src={sanitizeUrl(src)}
referrerPolicy={referrerPolicy}
allowFullScreen
{...rest}
/>
)}
{showBottomBar && (
<Bar>
{icon} <Title>{title}</Title>
{canonicalUrl && (
<Open href={canonicalUrl} target="_blank" rel="noopener noreferrer">
<OpenIcon size={18} /> Open
</Open>
)}
</Bar>
)}
</Rounded>
);
};
render() {
const {
border,
style = {},
forwardedRef,
icon,
title,
canonicalUrl,
isSelected,
referrerPolicy,
className = "",
src,
} = this.props;
const showBottomBar = !!(icon || canonicalUrl);
return (
<Rounded
style={style}
$showBottomBar={showBottomBar}
$border={border}
className={
isSelected ? `ProseMirror-selectednode ${className}` : className
}
>
{this.isLoaded && (
<Iframe
ref={forwardedRef}
$showBottomBar={showBottomBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms allow-downloads allow-storage-access-by-user-activation"
style={style}
frameBorder="0"
title="embed"
loading="lazy"
src={sanitizeUrl(src)}
referrerPolicy={referrerPolicy}
allowFullScreen
/>
)}
{showBottomBar && (
<Bar>
{icon} <Title>{title}</Title>
{canonicalUrl && (
<Open
href={canonicalUrl}
target="_blank"
rel="noopener noreferrer"
>
<OpenIcon size={18} /> Open
</Open>
)}
</Bar>
)}
</Rounded>
);
}
}
const Iframe = styled.iframe<{ $showBottomBar: boolean }>`
border-radius: ${(props) => (props.$showBottomBar ? "3px 3px 0 0" : "3px")};
+8 -220
View File
@@ -1,49 +1,17 @@
import { observer } from "mobx-react";
import {
DocumentIcon,
EmailIcon,
CollectionIcon,
WarningIcon,
} from "outline-icons";
import { DocumentIcon, EmailIcon, CollectionIcon } 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";
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,
};
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 };
};
export const MentionUser = observer(function MentionUser_(
@@ -52,7 +20,7 @@ export const MentionUser = observer(function MentionUser_(
const { isSelected, node } = props;
const { users } = useStores();
const user = users.get(node.attrs.modelId);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
const { className, ...attrs } = getAttributesFromNode(node);
return (
<span
@@ -74,7 +42,7 @@ export const MentionDocument = observer(function MentionDocument_(
const { documents } = useStores();
const doc = documents.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
const { className, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
@@ -107,7 +75,7 @@ export const MentionCollection = observer(function MentionCollection_(
const { collections } = useStores();
const collection = collections.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
const { className, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
@@ -132,183 +100,3 @@ 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>
);
});
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;
`;
+1 -10
View File
@@ -128,7 +128,6 @@ const mathStyle = (props: Props) => css`
math-block .math-src .ProseMirror {
width: 100%;
display: block;
outline: none;
}
math-block .katex-display {
@@ -335,7 +334,7 @@ width: 100%;
box-sizing: content-box;
}
& > .ProseMirror {
.ProseMirror {
position: relative;
outline: none;
word-wrap: break-word;
@@ -708,7 +707,6 @@ img.ProseMirror-separator {
resize: none;
user-select: text;
margin: 0 auto !important;
max-width: 100vw;
}
.ProseMirror[contenteditable="false"] {
@@ -1337,13 +1335,6 @@ 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 -2
View File
@@ -1,4 +1,3 @@
import { BrowserIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { Primitive } from "utility-types";
@@ -666,7 +665,7 @@ const embeds: EmbedDescriptor[] = [
title: "Embed",
keywords: "iframe webpage",
placeholder: "Paste a URL to embed",
icon: <BrowserIcon />,
icon: <Img src="/images/embed.png" alt="Embed" />,
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 { getLoaderForLanguage, getRefractorLangForLanguage } from "../lib/code";
import { getPrismLangForLanguage } from "../lib/code";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
@@ -14,44 +14,6 @@ type ParsedNode = {
};
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
const languagesToImport = new Set<string>();
const languagePromises: Record<
string,
Promise<string | undefined> | undefined
> = {};
async function loadLanguage(language: string) {
if (!language || refractor.registered(language)) {
return;
}
if (languagePromises[language]) {
return languagePromises[language];
}
const loader = getLoaderForLanguage(language);
if (!loader) {
return;
}
languagePromises[language] = loader()
.then((syntax) => {
refractor.register(syntax);
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
);
delete languagePromises[language]; // Remove failed promise from cache
return undefined;
});
return languagePromises[language];
}
function getDecorations({
doc,
@@ -95,8 +57,12 @@ function getDecorations({
blocks.forEach((block) => {
let startPos = block.pos + 1;
const language = block.node.attrs.language;
const lang = getRefractorLangForLanguage(language);
const language = getPrismLangForLanguage(block.node.attrs.language);
if (!language || !refractor.registered(language)) {
return;
}
const lineDecorations = [];
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
@@ -125,48 +91,35 @@ 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: lineDecorations,
decorations: newDecorations,
};
if (!lang) {
// do nothing
} else if (refractor.registered(lang)) {
languagesToImport.delete(language);
const nodes = refractor.highlight(block.node.textContent, lang);
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);
});
});
@@ -180,7 +133,7 @@ function getDecorations({
return DecorationSet.create(doc, decorations);
}
export function CodeHighlighting({
export default function Prism({
name,
lineNumbers,
}: {
@@ -192,7 +145,7 @@ export function CodeHighlighting({
let highlighted = false;
return new Plugin({
key: new PluginKey("codeHighlighting"),
key: new PluginKey("prism"),
state: {
init: (_, { doc }) => DecorationSet.create(doc, []),
apply: (transaction: Transaction, decorationSet, oldState, state) => {
@@ -203,13 +156,11 @@ export function CodeHighlighting({
// @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;
@@ -223,35 +174,15 @@ export function CodeHighlighting({
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 highlighting
// it render un-highlighted and then trigger a defered render of Prism
// by updating the plugins metadata
requestAnimationFrame(() => {
setTimeout(() => {
if (!view.isDestroyed) {
view.dispatch(
view.state.tr.setMeta("codeHighlighting", { loaded: true })
);
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
}
});
}, 10);
}
return {
update: () => {
if (!languagesToImport.size) {
return;
}
void Promise.all([...languagesToImport].map(loadLanguage)).then(
(language) => {
if (language && languagesToImport.size) {
view.dispatch(
view.state.tr.setMeta("codeHighlighting", {
langLoaded: language,
})
);
}
}
);
},
};
return {};
},
props: {
decorations(state) {
+8 -8
View File
@@ -1,12 +1,12 @@
import { getRefractorLangForLanguage, getLabelForLanguage } from "./code";
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
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();
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();
});
});
+62 -317
View File
@@ -1,313 +1,69 @@
import type { RefractorSyntax } from "refractor";
import Storage from "../../utils/Storage";
const RecentlyUsedStorageKey = "rme-code-language";
const RecentStorageKey = "rme-code-language";
const StorageKey = "frequent-code-languages";
const frequentLanguagesToGet = 5;
const frequentLanguagesToTrack = 10;
type CodeLanguage = {
lang: string;
label: string;
loader?: () => Promise<RefractorSyntax>;
};
/**
* List of supported code languages.
*
* Object key is the language identifier used in the editor, lang is the
* language identifier used by Refractor. Note mismatches such as `markup` and
* language identifier used by Prism. Note mismatches such as `markup` and
* `mermaid`.
*/
export const codeLanguages: Record<string, CodeLanguage> = {
export const codeLanguages = {
none: { lang: "", label: "Plain text" },
bash: {
lang: "bash",
label: "Bash",
loader: () => import("refractor/lang/bash").then((m) => m.default),
},
clike: {
lang: "clike",
label: "C",
loader: () => import("refractor/lang/clike").then((m) => m.default),
},
cpp: {
lang: "cpp",
label: "C++",
loader: () => import("refractor/lang/cpp").then((m) => m.default),
},
csharp: {
lang: "csharp",
label: "C#",
loader: () => import("refractor/lang/csharp").then((m) => m.default),
},
css: {
lang: "css",
label: "CSS",
loader: () => import("refractor/lang/css").then((m) => m.default),
},
csv: {
lang: "csv",
label: "CSV",
loader: () => import("refractor/lang/csv").then((m) => m.default),
},
docker: {
lang: "docker",
label: "Docker",
loader: () => import("refractor/lang/docker").then((m) => m.default),
},
elixir: {
lang: "elixir",
label: "Elixir",
loader: () => import("refractor/lang/elixir").then((m) => m.default),
},
erb: {
lang: "erb",
label: "ERB",
loader: () => import("refractor/lang/erb").then((m) => m.default),
},
erlang: {
lang: "erlang",
label: "Erlang",
loader: () => import("refractor/lang/erlang").then((m) => m.default),
},
go: {
lang: "go",
label: "Go",
loader: () => import("refractor/lang/go").then((m) => m.default),
},
graphql: {
lang: "graphql",
label: "GraphQL",
loader: () => import("refractor/lang/graphql").then((m) => m.default),
},
groovy: {
lang: "groovy",
label: "Groovy",
loader: () => import("refractor/lang/groovy").then((m) => m.default),
},
haskell: {
lang: "haskell",
label: "Haskell",
loader: () => import("refractor/lang/haskell").then((m) => m.default),
},
hcl: {
lang: "hcl",
label: "HCL",
loader: () => import("refractor/lang/hcl").then((m) => m.default),
},
markup: {
lang: "markup",
label: "HTML",
loader: () => import("refractor/lang/markup").then((m) => m.default),
},
ini: {
lang: "ini",
label: "INI",
loader: () => import("refractor/lang/ini").then((m) => m.default),
},
java: {
lang: "java",
label: "Java",
loader: () => import("refractor/lang/java").then((m) => m.default),
},
javascript: {
lang: "javascript",
label: "JavaScript",
loader: () => import("refractor/lang/javascript").then((m) => m.default),
},
json: {
lang: "json",
label: "JSON",
loader: () => import("refractor/lang/json").then((m) => m.default),
},
jsx: {
lang: "jsx",
label: "JSX",
loader: () => import("refractor/lang/jsx").then((m) => m.default),
},
kotlin: {
lang: "kotlin",
label: "Kotlin",
loader: () => import("refractor/lang/kotlin").then((m) => m.default),
},
kusto: {
lang: "kusto",
label: "Kusto",
// @ts-expect-error Mermaid is not in types but exists
loader: () => import("refractor/lang/kusto").then((m) => m.default),
},
lisp: {
lang: "lisp",
label: "Lisp",
loader: () => import("refractor/lang/lisp").then((m) => m.default),
},
lua: {
lang: "lua",
label: "Lua",
loader: () => import("refractor/lang/lua").then((m) => m.default),
},
makefile: {
lang: "makefile",
label: "Makefile",
loader: () => import("refractor/lang/makefile").then((m) => m.default),
},
markdown: {
lang: "markdown",
label: "Markdown",
loader: () => import("refractor/lang/markdown").then((m) => m.default),
},
mermaidjs: {
lang: "mermaid",
label: "Mermaid Diagram",
// @ts-expect-error Mermaid is not in types but exists
loader: () => import("refractor/lang/mermaid").then((m) => m.default),
},
nginx: {
lang: "nginx",
label: "Nginx",
loader: () => import("refractor/lang/nginx").then((m) => m.default),
},
nix: {
lang: "nix",
label: "Nix",
loader: () => import("refractor/lang/nix").then((m) => m.default),
},
objectivec: {
lang: "objectivec",
label: "Objective-C",
loader: () => import("refractor/lang/objectivec").then((m) => m.default),
},
ocaml: {
lang: "ocaml",
label: "OCaml",
loader: () => import("refractor/lang/ocaml").then((m) => m.default),
},
perl: {
lang: "perl",
label: "Perl",
loader: () => import("refractor/lang/perl").then((m) => m.default),
},
php: {
lang: "php",
label: "PHP",
loader: () => import("refractor/lang/php").then((m) => m.default),
},
powershell: {
lang: "powershell",
label: "Powershell",
loader: () => import("refractor/lang/powershell").then((m) => m.default),
},
protobuf: {
lang: "protobuf",
label: "Protobuf",
loader: () => import("refractor/lang/protobuf").then((m) => m.default),
},
python: {
lang: "python",
label: "Python",
loader: () => import("refractor/lang/python").then((m) => m.default),
},
r: {
lang: "r",
label: "R",
loader: () => import("refractor/lang/r").then((m) => m.default),
},
regex: {
lang: "regex",
label: "Regex",
loader: () => import("refractor/lang/regex").then((m) => m.default),
},
ruby: {
lang: "ruby",
label: "Ruby",
loader: () => import("refractor/lang/ruby").then((m) => m.default),
},
rust: {
lang: "rust",
label: "Rust",
loader: () => import("refractor/lang/rust").then((m) => m.default),
},
scala: {
lang: "scala",
label: "Scala",
loader: () => import("refractor/lang/scala").then((m) => m.default),
},
sass: {
lang: "sass",
label: "Sass",
loader: () => import("refractor/lang/sass").then((m) => m.default),
},
scss: {
lang: "scss",
label: "SCSS",
loader: () => import("refractor/lang/scss").then((m) => m.default),
},
"splunk-spl": {
lang: "splunk-spl",
label: "Splunk SPL",
loader: () => import("refractor/lang/splunk-spl").then((m) => m.default),
},
sql: {
lang: "sql",
label: "SQL",
loader: () => import("refractor/lang/sql").then((m) => m.default),
},
solidity: {
lang: "solidity",
label: "Solidity",
loader: () => import("refractor/lang/solidity").then((m) => m.default),
},
swift: {
lang: "swift",
label: "Swift",
loader: () => import("refractor/lang/swift").then((m) => m.default),
},
toml: {
lang: "toml",
label: "TOML",
loader: () => import("refractor/lang/toml").then((m) => m.default),
},
tsx: {
lang: "tsx",
label: "TSX",
loader: () => import("refractor/lang/tsx").then((m) => m.default),
},
typescript: {
lang: "typescript",
label: "TypeScript",
loader: () => import("refractor/lang/typescript").then((m) => m.default),
},
vb: {
lang: "vbnet",
label: "Visual Basic",
loader: () => import("refractor/lang/vbnet").then((m) => m.default),
},
verilog: {
lang: "verilog",
label: "Verilog",
loader: () => import("refractor/lang/verilog").then((m) => m.default),
},
vhdl: {
lang: "vhdl",
label: "VHDL",
loader: () => import("refractor/lang/vhdl").then((m) => m.default),
},
yaml: {
lang: "yaml",
label: "YAML",
loader: () => import("refractor/lang/yaml").then((m) => m.default),
},
xml: {
lang: "markup",
label: "XML",
loader: () => import("refractor/lang/markup").then((m) => m.default),
},
zig: {
lang: "zig",
label: "Zig",
loader: () => import("refractor/lang/zig").then((m) => m.default),
},
bash: { lang: "bash", label: "Bash" },
clike: { lang: "clike", label: "C" },
cpp: { lang: "cpp", label: "C++" },
csharp: { lang: "csharp", label: "C#" },
css: { lang: "css", label: "CSS" },
docker: { lang: "docker", label: "Docker" },
elixir: { lang: "elixir", label: "Elixir" },
erlang: { lang: "erlang", label: "Erlang" },
go: { lang: "go", label: "Go" },
graphql: { lang: "graphql", label: "GraphQL" },
groovy: { lang: "groovy", label: "Groovy" },
haskell: { lang: "haskell", label: "Haskell" },
hcl: { lang: "hcl", label: "HCL" },
markup: { lang: "markup", label: "HTML" },
ini: { lang: "ini", label: "INI" },
java: { lang: "java", label: "Java" },
javascript: { lang: "javascript", label: "JavaScript" },
json: { lang: "json", label: "JSON" },
jsx: { lang: "jsx", label: "JSX" },
kotlin: { lang: "kotlin", label: "Kotlin" },
lisp: { lang: "lisp", label: "Lisp" },
lua: { lang: "lua", label: "Lua" },
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
nginx: { lang: "nginx", label: "Nginx" },
nix: { lang: "nix", label: "Nix" },
objectivec: { lang: "objectivec", label: "Objective-C" },
ocaml: { lang: "ocaml", label: "OCaml" },
perl: { lang: "perl", label: "Perl" },
php: { lang: "php", label: "PHP" },
powershell: { lang: "powershell", label: "Powershell" },
protobuf: { lang: "protobuf", label: "Protobuf" },
python: { lang: "python", label: "Python" },
r: { lang: "r", label: "R" },
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" },
sql: { lang: "sql", label: "SQL" },
solidity: { lang: "solidity", label: "Solidity" },
swift: { lang: "swift", label: "Swift" },
toml: { lang: "toml", label: "TOML" },
tsx: { lang: "tsx", label: "TSX" },
typescript: { lang: "typescript", label: "TypeScript" },
vb: { lang: "vb", label: "Visual Basic" },
verilog: { lang: "verilog", label: "Verilog" },
vhdl: { lang: "vhdl", label: "VHDL" },
yaml: { lang: "yaml", label: "YAML" },
xml: { lang: "markup", label: "XML" },
zig: { lang: "zig", label: "Zig" },
};
/**
@@ -323,38 +79,27 @@ export const getLabelForLanguage = (language: string) => {
};
/**
* Get the Refractor language identifier for a given language.
* Get the Prism language identifier for a given language.
*
* @param language The language identifier.
* @returns The Refractor language identifier for the language.
* @returns The Prism language identifier for the language.
*/
export const getRefractorLangForLanguage = (
language: string
): string | undefined =>
export const getPrismLangForLanguage = (language: string): string | undefined =>
codeLanguages[language as keyof typeof codeLanguages]?.lang;
/**
* Get the loader function for a given language.
*
* @param language The language identifier.
* @returns The loader function for the language, or undefined if not available.
*/
export const getLoaderForLanguage = (language: string) =>
codeLanguages[language as keyof typeof codeLanguages]?.loader;
/**
* Set the most recent code language used.
*
* @param language The language identifier.
*/
export const setRecentlyUsedCodeLanguage = (language: string) => {
export const setRecentCodeLanguage = (language: string) => {
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
string,
number
>;
if (Object.keys(frequentLangs).length === 0) {
const lastUsedLang = Storage.get(RecentlyUsedStorageKey);
const lastUsedLang = Storage.get(RecentStorageKey);
if (lastUsedLang) {
frequentLangs[lastUsedLang] = 1;
}
@@ -376,7 +121,7 @@ export const setRecentlyUsedCodeLanguage = (language: string) => {
}
Storage.set(StorageKey, Object.fromEntries(frequentLangEntries));
Storage.set(RecentlyUsedStorageKey, language);
Storage.set(RecentStorageKey, language);
};
/**
@@ -384,8 +129,8 @@ export const setRecentlyUsedCodeLanguage = (language: string) => {
*
* @returns The most recent code language used, or undefined if none is set.
*/
export const getRecentlyUsedCodeLanguage = () =>
Storage.get(RecentlyUsedStorageKey) as keyof typeof codeLanguages | undefined;
export const getRecentCodeLanguage = () =>
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
/**
* Get the most frequent code languages used.
@@ -393,7 +138,7 @@ export const getRecentlyUsedCodeLanguage = () =>
* @returns An array of the most frequent code languages used.
*/
export const getFrequentCodeLanguages = () => {
const recentLang = Storage.get(RecentlyUsedStorageKey);
const recentLang = Storage.get(RecentStorageKey);
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
keyof typeof codeLanguages,
number
+52 -87
View File
@@ -47,66 +47,6 @@ export default class Code extends Mark {
markType: this.editor.schema.marks.code_inline,
})[0];
/**
* Helper function to check if cursor is between backticks
* and handle the code marking appropriately
*/
const handleTextBetweenBackticks = (
view: EditorView,
from: number,
to: number,
text: string | Slice
) => {
const { state } = view;
// Prevent access out of document bounds
if (from === 0 || to === state.doc.nodeSize - 1) {
return false;
}
// Skip if we're adding a backtick character
if (typeof text === "string" && text === "`") {
return false;
}
// Check if we're between backticks
if (
state.doc.textBetween(from - 1, from) === "`" &&
state.doc.textBetween(to, to + 1) === "`"
) {
const start = from - 1;
const end = to + 1;
if (typeof text === "string") {
// Handle text input
view.dispatch(
state.tr
.delete(start, end)
.insertText(text, start)
.addMark(
start,
start + text.length,
state.schema.marks.code_inline.create()
)
);
} else {
// Handle paste/slice
view.dispatch(
state.tr
.replaceRange(start, end, text)
.addMark(
start,
start + text.size,
state.schema.marks.code_inline.create()
)
);
}
return true;
}
return false;
};
return [
codeCursorPlugin,
new Plugin({
@@ -119,11 +59,34 @@ export default class Code extends Mark {
to: number,
text: string
) => {
// Skip this handler during IME composition or it will prevent the
if (view.composing) {
const { state } = view;
// Prevent access out of document bounds
if (from === 0 || to === state.doc.nodeSize - 1 || text === "`") {
return false;
}
return handleTextBetweenBackticks(view, from, to, text);
if (
from === to &&
state.doc.textBetween(from - 1, from) === "`" &&
state.doc.textBetween(to, to + 1) === "`"
) {
const start = from - 1;
const end = to + 1;
view.dispatch(
state.tr
.delete(start, end)
.insertText(text, start)
.addMark(
start,
start + text.length,
state.schema.marks.code_inline.create()
)
);
return true;
}
return false;
},
// Pasting a character inside of two backticks will wrap the character
@@ -131,7 +94,32 @@ export default class Code extends Mark {
handlePaste: (view: EditorView, _event: Event, slice: Slice) => {
const { state } = view;
const { from, to } = state.selection;
return handleTextBetweenBackticks(view, from, to, slice);
// Prevent access out of document bounds
if (from === 0 || to === state.doc.nodeSize - 1) {
return false;
}
const start = from - 1;
const end = to + 1;
if (
from === to &&
state.doc.textBetween(start, from) === "`" &&
state.doc.textBetween(to, end) === "`"
) {
view.dispatch(
state.tr
.replaceRange(start, end, slice)
.addMark(
start,
start + slice.size,
state.schema.marks.code_inline.create()
)
);
return true;
}
return false;
},
// Triple clicking inside of an inline code mark will select the entire
@@ -155,29 +143,6 @@ export default class Code extends Mark {
return false;
},
// Handle composition end events for IME input
handleDOMEvents: {
compositionend: (view: EditorView) => {
setTimeout(() => {
const { $cursor } = view.state.selection as TextSelection;
if (!$cursor) {
return;
}
const from = $cursor.pos - 1;
const to = $cursor.pos;
// Process the composed text after IME composition completes
handleTextBetweenBackticks(
view,
from,
to,
view.state.doc.textBetween(from, to)
);
});
},
},
},
}),
];
+110 -9
View File
@@ -9,6 +9,58 @@ 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";
@@ -25,12 +77,9 @@ 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 {
getRecentlyUsedCodeLanguage,
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import Prism from "../extensions/Prism";
import { getRecentCodeLanguage, setRecentCodeLanguage } from "../lib/code";
import { isCode } from "../lib/isCode";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
@@ -41,6 +90,58 @@ 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;
@@ -111,10 +212,10 @@ export default class CodeFence extends Node {
return {
code_block: (attrs: Record<string, Primitive>) => {
if (attrs?.language) {
setRecentlyUsedCodeLanguage(attrs.language as string);
setRecentCodeLanguage(attrs.language as string);
}
return toggleBlockType(type, schema.nodes.paragraph, {
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
...attrs,
});
},
@@ -185,7 +286,7 @@ export default class CodeFence extends Node {
get plugins() {
return [
CodeHighlighting({
Prism({
name: this.name,
lineNumbers: this.showLineNumbers,
}),
@@ -252,7 +353,7 @@ export default class CodeFence extends Node {
inputRules({ type }: { type: NodeType }) {
return [
textblockTypeInputRule(/^```$/, type, () => ({
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
})),
];
}
+1 -5
View File
@@ -408,14 +408,10 @@ export default class Image extends SimpleImage {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
let layoutClass: string | null = "full-width";
if (state.selection.node.attrs.layoutClass === layoutClass) {
layoutClass = null;
}
const attrs = {
...state.selection.node.attrs,
title: null,
layoutClass,
layoutClass: "full-width",
};
const { selection } = state;
dispatch?.(state.tr.setNodeMarkup(selection.from, undefined, attrs));
+15 -88
View File
@@ -1,4 +1,3 @@
import isMatch from "lodash/isMatch";
import { Token } from "markdown-it";
import {
NodeSpec,
@@ -16,12 +15,10 @@ import * as React from "react";
import { Primitive } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import { MentionType, UnfurlResourceType, UnfurlResponse } from "../../types";
import { MentionType } from "../../types";
import {
MentionCollection,
MentionDocument,
MentionIssue,
MentionPullRequest,
MentionUser,
} from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
@@ -53,12 +50,6 @@ export default class Mention extends Node {
id: {
default: undefined,
},
href: {
default: undefined,
},
unfurl: {
default: undefined,
},
},
inline: true,
marks: "",
@@ -82,10 +73,6 @@ 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,
};
},
},
@@ -100,18 +87,11 @@ export default class Mention extends Node {
? undefined
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
: node.attrs.type === MentionType.Collection
? `${env.URL}/collection/${node.attrs.modelId}`
: node.attrs.href,
: `${env.URL}/collection/${node.attrs.modelId}`,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorid": node.attrs.actorId,
"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),
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
},
toPlainText(node),
],
@@ -127,20 +107,6 @@ 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)}
/>
);
default:
return null;
}
@@ -183,42 +149,29 @@ 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 &&
NavigableMention.includes(selection.node.attrs.type)
(selection.node.attrs.type === MentionType.Document ||
selection.node.attrs.type === MentionType.Collection)
) {
const mentionType = selection.node.attrs.type;
const { modelId } = selection.node.attrs;
let link: string;
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: selection.node.attrs.type === MentionType.Collection
? "collection"
: undefined;
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}`;
if (!linkType) {
return false;
}
this.editor.props.onClickLink?.(link);
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
return true;
}
return false;
@@ -265,30 +218,4 @@ 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);
}
};
}
+4 -16
View File
@@ -1,5 +1,5 @@
import { NodeType } from "prosemirror-model";
import { EditorState, NodeSelection } from "prosemirror-state";
import { EditorState } from "prosemirror-state";
import { Primitive } from "utility-types";
import { findParentNode } from "./findParentNode";
@@ -25,24 +25,12 @@ export const isNodeActive =
return false;
}
let nodeWithPos;
const { from, to } = state.selection;
if (
state.selection instanceof NodeSelection &&
state.selection.node.type === type &&
state.selection.node.hasMarkup(type, {
...state.selection.node.attrs,
...attrs,
})
) {
nodeWithPos = { pos: from, node: state.selection.node };
}
nodeWithPos ??= findParentNode(
const nodeWithPos = findParentNode(
(node) =>
node.type === type &&
(!attrs || node.hasMarkup(type, { ...node.attrs, ...attrs }))
(!attrs ||
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
)(state.selection);
if (!nodeWithPos) {
-19
View File
@@ -1,19 +0,0 @@
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, []);
}
+89 -99
View File
@@ -11,17 +11,13 @@
"Search in collection": "Hledat ve sbírce",
"Star": "Přidat mezi oblíbené",
"Unstar": "Odstranit z oblíbených",
"Subscribe": "Přihlásit k odběru",
"Subscribed to document notifications": "Přihlášen k odběru upozornění ohledně dokumentů",
"Unsubscribe": "Odhlásit z odběru upozornění",
"Unsubscribed from document notifications": "Ohlášen odběr upozornění ohledně dokumentů",
"Archive": "Archiv",
"Archive collection": "Archivovat sbírku",
"Collection archived": "Sbírka archivována",
"Archive collection": "Archive collection",
"Collection archived": "Collection archived",
"Archiving": "Probíhá archivace",
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archivací této sbírky dojde také k archivaci všech dokumentů v ní. Dokumenty z kolekce již nebudou viditelné ve výsledcích vyhledávání.",
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.",
"Restore": "Obnovit",
"Collection restored": "Sbírka obnovena",
"Collection restored": "Collection restored",
"Delete": "Odstranit",
"Delete collection": "Odstranit sbírku",
"New template": "Nová šablona",
@@ -29,8 +25,8 @@
"Mark as resolved": "Označit jako vyřešené",
"Thread resolved": "Vlákno vyřešeno",
"Mark as unresolved": "Označit jako nevyřešené",
"View reactions": "Prohlédnout reakce",
"Reactions": "Reakce",
"View reactions": "View reactions",
"Reactions": "Reactions",
"Copy ID": "Kopírovat ID",
"Clear IndexedDB cache": "Smazat mezipaměť IndexedDB",
"IndexedDB cache cleared": "Mezipaměť indexedDB vymazána",
@@ -40,7 +36,7 @@
"Development": "Vývoj",
"Open document": "Otevřít dokument",
"New document": "Nový dokument",
"New draft": "Nový koncept",
"New draft": "New draft",
"New from template": "Nový ze šablony",
"New nested document": "Nový vnořený dokument",
"Publish": "Zveřejnit",
@@ -48,6 +44,10 @@
"Publish document": "Zveřejnit dokument",
"Unpublish": "Zrušit zveřejnění",
"Unpublished {{ documentName }}": "Nepublikováno {{ documentName }}",
"Subscribe": "Přihlásit k odběru",
"Subscribed to document notifications": "Přihlášen k odběru upozornění ohledně dokumentů",
"Unsubscribe": "Odhlásit z odběru upozornění",
"Unsubscribed from document notifications": "Ohlášen odběr upozornění ohledně dokumentů",
"Share this document": "Sdílet tento dokument",
"HTML": "HTML",
"PDF": "PDF",
@@ -57,8 +57,6 @@
"Download document": "Stáhnout dokument",
"Copy as Markdown": "Kopírovat jako Markdown",
"Markdown copied to clipboard": "Markdown zkopírován do schránky",
"Copy as text": "Copy as text",
"Text copied to clipboard": "Text copied to clipboard",
"Copy public link": "Zkopírovat veřejný odkaz",
"Link copied to clipboard": "Odkaz zkopírován do schránky",
"Copy link": "Kopírovat odkaz",
@@ -84,9 +82,9 @@
"Move": "Přesunout",
"Move to collection": "Přesunout do sbírky",
"Move {{ documentType }}": "Přesunout {{ documentType }}",
"Are you sure you want to archive this document?": "Opravdu chcete archivovat tento dokument?",
"Are you sure you want to archive this document?": "Are you sure you want to archive this document?",
"Document archived": "Dokument archivován",
"Archiving this document will remove it from the collection and search results.": "Archivací tento dokument odstraníte z kolekce a výsledků vyhledávání.",
"Archiving this document will remove it from the collection and search results.": "Archiving this document will remove it from the collection and search results.",
"Delete {{ documentName }}": "Odstranit {{ documentName }}",
"Permanently delete": "Trvale odstranit",
"Permanently delete {{ documentName }}": "Trvale odstranit {{ documentName }}",
@@ -97,12 +95,11 @@
"Insights": "Přehledy",
"Disable viewer insights": "Vypnout analytika nahlížení",
"Enable viewer insights": "Zapnout analytika nahlížení",
"Leave document": "Opustit dokument",
"You have left the shared document": "Opustil jste sdílený dokument",
"Could not leave document": "Nepodařilo se opustit dokument",
"Leave document": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"Home": "Domovská stránka",
"Drafts": "Koncepty",
"Search": "Hledat",
"Trash": "Koš",
"Settings": "Nastavení",
"Profile": "Profil",
@@ -140,7 +137,6 @@
"Update role": "Aktualizovat roli",
"Delete user": "Smazat uživatele",
"Collection": "Sbírka",
"Collections": "Sbírky",
"Debug": "Odstranit vývojářskou chybu",
"Document": "Dokument",
"Documents": "Dokumenty",
@@ -164,7 +160,7 @@
"Saving": "Uložení",
"Save": "Uložit",
"Creating": "Vytváření",
"Create": "Vytvořit",
"Create": "Vytořit",
"Collection deleted": "Kolekce odstraněna",
"Im sure Delete": "Ano, smazat",
"Deleting": "Mazání",
@@ -177,14 +173,14 @@
"Are you sure you want to permanently delete this entire comment thread?": "Jste si jisti, že chcete natrvalo odstranit vlákno komentářů?",
"Are you sure you want to permanently delete this comment?": "Jste si jisti, že chcete natrvalo odstranit komentář?",
"Confirm": "Potvrdit",
"manage access": "spravovat přístup",
"manage access": "manage access",
"view and edit access": "přístup k prohlížení a úpravám",
"view only access": "přístup pouze pro čtení",
"no access": "bez přístupu",
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "Nemáte oprávnění přesunout {{ documentName }} do sbírky {{ collectionName }}",
"You do not have permission to move {{ documentName }} to the {{ collectionName }} collection": "You do not have permission to move {{ documentName }} to the {{ collectionName }} collection",
"Move document": "Přesunout dokument",
"Moving": "Přesouvání",
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Přesunutím dokumentu <em>{{ title }}</em> do sbírky {{ newCollectionName }} se změní oprávnění pro všechny členy pracovního prostoru z <em>{{ prevPermission }}</em> na <em>{{ newPermission }}</em>.",
"Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.": "Moving the document <em>{{ title }}</em> to the {{ newCollectionName }} collection will change permission for all workspace members from <em>{{ prevPermission }}</em> to <em>{{ newPermission }}</em>.",
"Document is too large": "Dokument je příliš velký",
"This document has reached the maximum size and can no longer be edited": "Tento dokument dosáhl maximální velikosti a nelze jej dále upravovat",
"Authentication failed": "Ověření selhalo",
@@ -198,17 +194,16 @@
"Submenu": "Podmenu",
"Collections could not be loaded, please reload the app": "Sbírky se nepodařilo načíst, prosím načtěte aplikaci znovu",
"Default collection": "Výchozí sbírka",
"Start view": "Domovská obrazovka",
"Install now": "Nainstalovat",
"Deleted Collection": "Odstraněná sbírka",
"Untitled": "Bez názvu",
"Unpin": "Zrušit připnutí",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Vyberte místo, kam chcete zkopírovat",
"Document copied": "Dokument byl zkopírován",
"Couldnt copy the document, try again?": "Dokument nelze zkopírovat, chcete to zkusit znovu?",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
"Include nested documents": "Zahrnout vnořené dokumenty",
"Copy to <em>{{ location }}</em>": "Zkopírovat do <em>{{ location }}</em>",
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Search collections & documents": "Prohledat sbírky a dokumenty",
"No results found": "Nebyly nalezeny žádné výsledky",
"New": "Nový",
@@ -294,6 +289,7 @@
"Flags": "Vlajky",
"Select a color": "Vybrat barvu",
"Loading": "Načítání",
"Search": "Hledat",
"Permission": "Oprávnění",
"View only": "Pouze zobrazit",
"Can edit": "Může upravovat",
@@ -308,14 +304,14 @@
"Unknown": "Neznámý",
"Mark all as read": "Označit vše jako přečtené",
"You're all caught up": "Již nic nového",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reagoval s {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} a {{ secondUsername }} reagovali s {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} a {{ count }} reagovali s {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} a {{ count }} reagovali s {{ emoji }}",
"Add reaction": "Přidat reakci",
"Reaction picker": "Výběr reakce",
"Could not load reactions": "Nepodařilo se načíst reakce",
"Reaction": "Reakce",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Add reaction": "Add reaction",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
"Results": "Výsledky",
"No results for {{query}}": "Žádné výsledky pro {{query}}",
"Manage": "Spravovat",
@@ -333,7 +329,7 @@
"Add or invite": "Přidat nebo pozvat",
"Viewer": "Prohlížející",
"Editor": "Editor",
"Suggestions for invitation": "Návrhy na pozvánku",
"Suggestions for invitation": "Suggestions for invitation",
"No matches": "Žádné výsledky",
"Can view": "Může prohlížet",
"Everyone in the collection": "Všichni v kolekci",
@@ -360,18 +356,19 @@
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Kdokoli s odkazem má přístup, protože dokument dědí oprávnění po nadřazeném dokumentu <2>{{documentTitle}}</2>",
"Allow anyone with the link to access": "Povolit přístup komukoliv s odkazem",
"Publish to internet": "Zveřejnit na internetu",
"Search engine indexing": "Indexace vyhledávání",
"Disable this setting to discourage search engines from indexing the page": "Zakažte toto nastavení, abyste odradili vyhledávače od indexování stránky",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Vložené dokumenty nejsou sdíleny na webu. Změnit sdílení pro povolení přístupu (toto bude v budoucnu výchozí chování)",
"{{ userName }} was added to the document": "{{ userName }} byl přidán do dokumentu",
"{{ count }} people added to the document": "{{ count }} lidí bylo přidáno do dokumentu",
"{{ count }} people added to the document_plural": "{{ count }} lidí bylo přidáno do dokumentu",
"{{ count }} groups added to the document": "{{ count }} skupin bylo přidáno do dokumentu",
"{{ count }} groups added to the document_plural": "{{ count }} skupin bylo přidáno do dokumentu",
"{{ userName }} was added to the document": "{{ userName }} was added to the document",
"{{ count }} people added to the document": "{{ count }} people added to the document",
"{{ count }} people added to the document_plural": "{{ count }} people added to the document",
"{{ count }} groups added to the document": "{{ count }} groups added to the document",
"{{ count }} groups added to the document_plural": "{{ count }} groups added to the document",
"Logo": "Logo",
"Archived collections": "Archivované sbírky",
"Archived collections": "Archived collections",
"New doc": "Nový dokument",
"Empty": "Prázdné",
"Collections": "Sbírky",
"Collapse": "Sbalit",
"Expand": "Rozbalit",
"Document not supported try Markdown, Plain text, HTML, or Word": "Dokument není podporován zkuste Markdown, Plain text, HTML nebo Word",
@@ -385,7 +382,7 @@
"Up to date": "Aktuální",
"{{ releasesBehind }} versions behind": "Zastaralá verze {{ releasesBehind }}",
"{{ releasesBehind }} versions behind_plural": "Zastaralé verze {{ releasesBehind }}",
"Change permissions?": "Změnit práva?",
"Change permissions?": "Change permissions?",
"{{ documentName }} cannot be moved within {{ parentDocumentName }}": "{{ documentName }} cannot be moved within {{ parentDocumentName }}",
"You can't reorder documents in an alphabetically sorted collection": "Nemůžete změnit pořadí dokumentů v abecedně seřazené sbírce",
"The {{ documentName }} cannot be moved here": "The {{ documentName }} cannot be moved here",
@@ -427,8 +424,7 @@
"Profile picture": "Profilový obrázek",
"Create a new doc": "Vytvořit nový dokument",
"{{ 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": "Zachovat jako odkaz",
"Mention": "Mention",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Přidat sloupec za",
"Add column before": "Přidat sloupec před",
@@ -462,7 +458,7 @@
"Big heading": "Velký nadpis",
"Medium heading": "Střední nadpis",
"Small heading": "Malý nadpis",
"Extra small heading": "Extra malý nadpis",
"Extra small heading": "Extra small heading",
"Heading": "Záhlaví",
"Divider": "Dělící čára",
"Image": "Obrázek",
@@ -510,7 +506,6 @@
"None": "None",
"Could not import file": "Soubor nelze importovat",
"Unsubscribed from document": "Upozornění vypnuta",
"Unsubscribed from collection": "Unsubscribed from collection",
"Account": "Účet",
"API Keys": "API Keys",
"Details": "Podrobnosti",
@@ -520,6 +515,7 @@
"Groups": "Skupiny",
"Shared Links": "Sdílené odkazy",
"Import": "Import",
"Self Hosted": "Vlastní hostování",
"Integrations": "Integrace",
"Revoke token": "Odvolat tokeny",
"Revoke": "Zrušit",
@@ -537,15 +533,12 @@
"{{ documentName }} restored": "{{ documentName }} restored",
"Document options": "Možnosti dokumentů",
"Choose a collection": "Vybrat sbírku",
"Subscription inherited from collection": "Subscription inherited from collection",
"Enable embeds": "Povolit embed vkládání",
"Export options": "Možnosti exportu",
"Group members": "Členové skupiny",
"Edit group": "Upravit skupinu",
"Delete group": "Odstranit skupinu",
"Group options": "Nastavení skupin",
"Cancel": "Zrušit",
"Import menu options": "Import menu options",
"Member options": "Uživatelská nastavení",
"New document in <em>{{ collectionName }}</em>": "Nový dokument v <em>{{ collectionName }}</em>",
"New child document": "Nový vložený dokument",
@@ -576,7 +569,7 @@
"created the collection": "vytvořil sbírku",
"mentioned you in": "zmínil vás v",
"left a comment on": "zanechal komentář k",
"resolved a comment on": "vyřešil komentář dne",
"resolved a comment on": "resolved a comment on",
"shared": "sdíleno",
"invited you to": "vás pozval/a do",
"Choose a date": "Vybrat datum",
@@ -605,7 +598,7 @@
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} skupin s přístupem",
"Archived by {{userName}}": "Archivoval {{userName}}",
"Share": "Sdílet",
"Overview": "Přehled",
"Overview": "Overview",
"Recently updated": "Nedávno aktualizováno",
"Recently published": "Nedávno zveřejněné",
"Least recently updated": "Naposledy aktualizováno",
@@ -617,14 +610,15 @@
"Add a reply": "Přidat odpověď",
"Reply": "Odpovědět",
"Post": "Odeslat",
"Cancel": "Zrušit",
"Upload image": "Nahrát obrázek",
"No resolved comments": "Žádné vyřešené komentáře",
"No comments yet": "Doposud žádné komentáře",
"New comments": "New comments",
"Sort comments": "Sort comments",
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Vyřešené",
"Sort comments": "Sort comments",
"Resolved": "Resolved",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply_plural": "Show {{ count }} replies",
"Error updating comment": "Chyba při aktualizaci komentáře",
@@ -705,11 +699,8 @@
"No documents found for your filters.": "Pro zadaný požadavek nebyly nalezeny žádné dokumenty.",
"Youve not got any drafts at the moment.": "Momentálně nemáte žádné koncepty.",
"Payment Required": "Vyžadována platba",
"No access to this doc": "No access to this doc",
"It doesnt look like you have permission to access this document.": "It doesnt look like you have permission to access this document.",
"Please request access from the document owner.": "Please request access from the document owner.",
"Not found": "Nenalezeno",
"The page youre looking for cannot be found. It might have been deleted or the link is incorrect.": "Stránka, kterou hledáte, nebyla nalezena. Možná byla odstraněna nebo odkaz není správný.",
"Not Found": "Nenalezeno",
"We were unable to find the page youre looking for. Go to the <2>homepage</2>?": "Nepodařilo se nám najít stránku, kterou hledáte. Chcete přejít na <2>domovskou stránku</2>?",
"Offline": "Offline",
"We were unable to load the document while offline.": "V režimu offline se nepodařilo načíst dokument.",
"Your account has been suspended": "Váš účet byl pozastaven",
@@ -738,7 +729,7 @@
"Inviting": "Pozvání",
"Send Invites": "Odeslat pozvánky",
"Open command menu": "Otevřít příkazovou řádku",
"Forward": "Přeposlat",
"Forward": "Forward",
"Edit current document": "Upravit tento dokument",
"Move current document": "Přesunout tento dokument",
"Open document history": "Otevřít historii dokumentu",
@@ -777,10 +768,10 @@
"LaTeX block": "Blok LaTeX",
"Inline code": "Vložený kód",
"Inline LaTeX": "Vložený LaTeX",
"Triggers": "Akce",
"Mention users and more": "Zmínit uživatele a další",
"Triggers": "Triggers",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Přidat blok",
"Insert block": "Insert block",
"Sign In": "Přihlásit se",
"Continue with Email": "Pokračovat pomocí e-mailu",
"Continue with {{ authProviderName }}": "Pokračovat s {{ authProviderName }}",
@@ -802,7 +793,7 @@
"Sorry, it looks like that sign-in link is no longer valid, please try requesting another.": "Je nám líto, zdá se, že tento odkaz pro přihlášení již není platný, zkuste prosím požádat o jiný.",
"Your account has been suspended. To re-activate your account, please contact a workspace admin.": "Váš účet byl pozastaven. Chcete-li znovu aktivovat svůj účet, kontaktujte správce pracovního prostoru.",
"This workspace has been suspended. Please contact support to restore access.": "Tento pracovní prostor byl pozastaven. Prosím kontaktujte podporu pro obnovení přístupu.",
"Authentication failed this login method was disabled by a workspace admin.": "Ověření se nezdařilo tento způsob přihlášení byl zakázán správcem týmu.",
"Authentication failed this login method was disabled by a team admin.": "Ověření se nezdařilo tento způsob přihlášení byl zakázán správcem týmu.",
"The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.": "Pracovní prostor, ke kterému se pokoušíte připojit, vyžaduje před vytvořením účtu pozvánku.<1></1> Požádejte správce pracovního prostoru o pozvánku a zkuste to znovu.",
"Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.",
"Login": "Přihlášení",
@@ -823,16 +814,17 @@
"Or": "Nebo",
"Already have an account? Go to <1>login</1>.": "Máte již účet? <1>Přihlaste se</1>.",
"Any collection": "Jakákoli sbírka",
"All time": "All time",
"Any time": "Kdykoliv",
"Past day": "Včera",
"Past week": "Minulý týden",
"Past month": "Minulý měsíc",
"Past year": "Minulý rok",
"Any time": "Kdykoliv",
"Remove document filter": "Odebrat filtr dokumentů",
"Any status": "Libovolný stav",
"Remove search": "Odstranit vyhledávání",
"Any author": "Jakýkoliv autor",
"Author": "Autor",
"We were unable to find the page youre looking for.": "Nepodařilo se nám najít stránku, kterou hledáte.",
"Search titles only": "Hledat pouze názvy",
"Something went wrong": "Something went wrong",
"Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists",
@@ -891,22 +883,16 @@
"Search people": "Hledat uživatele",
"No people matching your search": "Vašemu vyhledávání neodpovídají žádní lidé",
"No people left to add": "Nezbývají žádní lidé, které by bylo možné přidat",
"Date created": "Datum vytvoření",
"Date created": "Date created",
"Upload": "Nahrát",
"Crop image": "Crop image",
"Uploading": "Nahrávání",
"How does this work?": "Jak to funguje?",
"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>.": "Můžete importovat soubor zip, který byl dříve exportován z možnosti JSON v jiné instanci. V {{ appName }} otevřete <em>Export</em> v postranním panelu Nastavení a klikněte na <em>Exportovat data</em>.",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Přetáhněte soubor zip z možnosti exportu JSON v {{appName}} nebo kliknutím nahrajte",
"Canceled": "Zrušeno",
"Import canceled": "Import byl zrušen",
"Are you sure you want to cancel this import?": "Jste si jisti, že chcete zrušit tento import?",
"Canceling": "Probíhá zrušení",
"Canceling this import will discard any progress made. This cannot be undone.": "Zrušením tohoto importu bude odstraněn jakýkoliv pokrok. Toto nelze vrátit zpět.",
"{{ count }} document imported": "{{ count }} dokument importován",
"{{ count }} document imported_plural": "{{ count }} dokumentů importováno",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Můžete importovat soubor zip, který byl dříve exportován z instalace Outline budou importovány sbírky, dokumenty a obrázky. V aplikaci Outline otevřete <em>Export</em> na postranním panelu Nastavení a klikněte na <em>Exportovat data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Přetáhněte soubor zip z možnosti exportu Markdown z {{appName}} nebo kliknutím nahrajte",
"Where do I find the file?": "Kde najdu soubor?",
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "V Notion klikněte na <em>Nastavení a členové</em> v levém postranním panelu a otevřete Nastavení. Vyhledejte sekci Exportovat a klikněte na <em>Exportovat veškerý obsah pracovního prostoru</em>. Vyberte <em>HTML</em> jako formát pro nejlepší kompatibilitu dat.",
"Drag and drop the zip file from Notion's HTML export option, or click to upload": "Přetáhněte soubor zip z exportu HTML aplikace Notion nebo kliknutím nahrajte",
"Last active": "Poslední aktivita",
"Guest": "Host",
"Shared by": "Sdíleno uživatelem",
@@ -919,8 +905,6 @@
"Editors": "Editoři",
"All status": "All status",
"Active": "Aktivní",
"Left": "Vlevo",
"Right": "Vpravo",
"Settings saved": "Nastavení uloženo",
"Logo updated": "Logo aktualizováno",
"Unable to upload new logo": "Nelze nahrát nové logo",
@@ -938,10 +922,13 @@
"Show your teams logo on public pages like login and shared documents.": "Zobrazit logo vašeho týmu na veřejných stránkách, jako je přihlášení a sdílené dokumenty.",
"Table of contents position": "Umístění obsahu tabulky",
"The side to display the table of contents in relation to the main content.": "The side to display the table of contents in relation to the main content.",
"Left": "Left",
"Right": "Right",
"Behavior": "Chování",
"Subdomain": "Subdoména",
"Your workspace will be accessible at": "Váš pracovní prostor bude přístupný na",
"Choose a subdomain to enable a login page just for your team.": "Vyberte subdoménu a povolte přihlašovací stránku pouze pro svůj tým.",
"Start view": "Domovská obrazovka",
"This is the screen that workspace members will first see when they sign in.": "Toto je obrazovka, kterou členové pracovního prostoru uvidí jako první, když se přihlásí.",
"Danger": "Nebezpečí",
"You can delete this entire workspace including collections, documents, and users.": "Můžete odstranit celý tento pracovní prostor včetně kolekcí, dokumentů a uživatelů.",
@@ -958,37 +945,38 @@
"New group": "Nová skupina",
"Groups can be used to organize and manage the people on your team.": "Skupiny lze použít k organizaci a správě lidí ve vašem týmu.",
"No groups have been created yet": "Dosud nebyly vytvořeny žádné skupiny",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Rychle přeneste své stávající dokumenty, stránky a soubory z jiných nástrojů a služeb do {{appName}}. Jakékoli HTML, Markdown a textové dokumenty můžete také přetáhnout přímo do sbírky v aplikaci.",
"Import a zip file of Markdown documents (exported from version 0.67.0 or earlier)": "Importujte soubor zip s dokumenty Markdown (exportované z verze 0.67.0 nebo starší)",
"Import data": "Importovat data",
"Import a JSON data file exported from another {{ appName }} instance": "Importujte datový soubor JSON exportovaný z instance {{ appName }}",
"Import pages exported from Notion": "Importujte stránky exportované z Notion",
"Import pages from a Confluence instance": "Importujte stránky z aplikace Confluence",
"Enterprise": "Podnik",
"Quickly transfer your existing documents, pages, and files from other tools and services into {{appName}}. You can also drag and drop any HTML, Markdown, and text documents directly into Collections in the app.": "Rychle přeneste své stávající dokumenty, stránky a soubory z jiných nástrojů a služeb do {{appName}}. Jakékoli HTML, Markdown a textové dokumenty můžete také přetáhnout přímo do sbírky v aplikaci.",
"Recent imports": "Nedávné importy",
"Could not load members": "Could not load members",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Zde je uveden každý, kdo se přihlásil do {{appName}}. Je možné, že existuje více uživatelů, kteří mají přístup přes {{signinMethods}}, ale ještě se nepřihlásili.",
"Receive a notification whenever a new document is published": "Dostat upozornění, když bude publikován nový obsah",
"Receive a notification whenever a new document is published": "Přijímat oznámení, když bude publikován nový obsah",
"Document updated": "Dokument aktualizován",
"Receive a notification when a document you are subscribed to is edited": "Dostat upozornění, když je dokument, k jehož odběru jste přihlášeni, upraven",
"Receive a notification when a document you are subscribed to is edited": "Obdržet upozornění, když je dokument, k jehož odběru jste přihlášeni, upraven",
"Comment posted": "Komentář byl zveřejněn",
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Dostat upozornění, když dokument, k jehož odběru jste přihlášeni, nebo vlákno, jehož jste se účastnili, obdrží komentář",
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Obdržet upozornění, když dokument, k jehož odběru jste přihlášeni, nebo vlákno, jehož jste se účastnili, obdrží komentář",
"Mentioned": "Zmínky",
"Receive a notification when someone mentions you in a document or comment": "Dostat upozornění, když se o vás někdo zmíní v dokumentu nebo komentáři",
"Receive a notification when a comment thread you were involved in is resolved": "Dostat upozornění, když bude vyřešeno vlákno komentářů, do kterého jste byli zapojeni",
"Receive a notification when someone mentions you in a document or comment": "Dostávat upozornění, když se o vás někdo zmíní v dokumentu nebo komentáři",
"Receive a notification when a comment thread you were involved in is resolved": "Receive a notification when a comment thread you were involved in is resolved",
"Collection created": "Sbírka vytvořena",
"Receive a notification whenever a new collection is created": "Dostat upozornění, když bude vytvořena nová sbírka",
"Receive a notification whenever a new collection is created": "Přijímat oznámení, když bude vytvořena nová sbírka",
"Invite accepted": "Pozvánka přijata",
"Receive a notification when someone you invited creates an account": "Dostat upozornění, když si někdo, koho jste pozvali, vytvoří účet",
"Receive a notification when someone you invited creates an account": "Obdržet oznámení, když si někdo, koho jste pozvali, vytvoří účet",
"Invited to document": "Pozván/a do dokumentu",
"Receive a notification when a document is shared with you": "Dostat oznámení, když získáte přístup k dokumentu",
"Invited to collection": "Pozván do kolekce",
"Receive a notification when you are given access to a collection": "Dostat upozornění, když získáte přístup k nové kolekci",
"Receive a notification when you are given access to a collection": "Dostávat upozornění, když získáte přístup k nové kolekci",
"Export completed": "Export dokončen",
"Receive a notification when an export you requested has been completed": "Dostat upozornění, když byl vámi požadovaný export dokončen",
"Receive a notification when an export you requested has been completed": "Obdržet upozornění, když byl vámi požadovaný export dokončen",
"Getting started": "Začínáme",
"Tips on getting started with features and functionality": "Tipy, jak začít s funkcemi",
"New features": "Nové funkce",
"Receive an email when new features of note are added": "Dostat e-mail, když budou přidány nové funkce poznámky",
"Receive an email when new features of note are added": "Obdržet e-mail, když budou přidány nové funkce poznámky",
"Notifications saved": "Upozornění uložena",
"Unsubscription successful. Your notification settings were updated": "Odhlášení bylo úspěšné. Nastavení oznámení bylo aktualizováno",
"Manage when and where you receive email notifications.": "Spravujte, kdy a kde budete dostávat e-mailová upozornění.",
@@ -1008,8 +996,8 @@
"When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "Pokud je povoleno, dokumenty mají samostatný režim úprav. Pokud je zakázáno, dokumenty jsou vždy upravitelné, pokud máte oprávnění.",
"Remember previous location": "Zapamatovat předchozí umístění",
"Automatically return to the document you were last viewing when the app is re-opened.": "Automaticky se vracet k dokumentu, který jste si naposledy prohlédli před ukončením aplikace.",
"Smart text replacements": "Chytré nahrazení textu",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Automatické formátování textu nahrazením symboly, pomlčkami, chytrými uvozovkami a dalšími typografickými prvky.",
"Smart text replacements": "Smart text replacements",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
"You may delete your account at any time, note that this is unrecoverable": "Účet můžete kdykoliv odstranit, tento krok je neobnovitelný",
"Profile saved": "Profil uložen",
"Profile picture updated": "Profilový obrázek byl úspěšně aktualizován",
@@ -1044,6 +1032,10 @@
"Allow editors to create new collections within the workspace": "Umožnit členům vytvářet v rámci pracoviště nové kolekce",
"Workspace creation": "Vytvoření pracovního prostoru",
"Allow editors to create new workspaces": "Povolit editorům vytvářet nové pracovní prostory",
"Draw.io deployment": "Využití aplikace Draw.io",
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Přidejte sem svou vlastní instalační adresu draw.io, abyste povolili automatické vkládání diagramů do dokumentů.",
"Grist deployment": "Grist nasazení",
"Add your self-hosted grist installation URL here.": "Zde přidejte vlastní instalační Grist URL adresu.",
"Could not load shares": "Could not load shares",
"Sharing is currently disabled.": "Sdílení je momentálně zakázáno.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "Můžete globálně povolit a zakázat sdílení dokumentů v nastavení <em>zabezpečení</em>.",
@@ -1094,8 +1086,6 @@
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/": "URL adresa Matomo instance. Pokud používáte Matomo Cloud, adresa má na konci matomo.cloud/",
"Site ID": "ID stránky",
"An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.",
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?": "Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
"Import pages from Notion": "Import pages from Notion",
"Add to Slack": "Přidat do Slacku",
"document published": "dokument zveřejněn",
"document updated": "dokument aktualizován",
@@ -1152,5 +1142,5 @@
"{{ user }} updated {{ timeAgo }}": "{{ user }} aktualizoval před {{ timeAgo }}",
"You created {{ timeAgo }}": "Vytvořili jste před {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} vytvořil před {{ timeAgo }}",
"Error loading data": "Error loading data"
}
"Uploading": "Nahrávání"
}

Some files were not shown because too many files have changed in this diff Show More