Compare commits

..

52 Commits

Author SHA1 Message Date
Tom Moor bb6c15a552 chore: Always log outgoing emails in development 2025-03-15 14:18:16 -04:00
Tom Moor 7c41c1360b Double test timeout (#8696) 2025-03-14 19:51:06 -07:00
Tom Moor f3a1b47ccf fix: Styling of selected event list item (#8685) 2025-03-13 03:43:18 +00:00
Tom Moor af234465f0 fix: dd-trace upgrade causes errors/high memory consumption (#8684) 2025-03-13 01:48:30 +00:00
Tom Moor 5a1aeed989 fix: API middleware wrapper triggers on JSZip stream (#8683) 2025-03-13 00:50:41 +00:00
Tom Moor 6ea4ce72ec chore: Improve CSV output sanitization (#8682) 2025-03-13 00:23:48 +00:00
dependabot[bot] 8041d9c3bd chore(deps): bump prosemirror-tables from 1.4.0 to 1.6.4 (#8557)
Bumps [prosemirror-tables](https://github.com/prosemirror/prosemirror-tables) from 1.4.0 to 1.6.4.
- [Release notes](https://github.com/prosemirror/prosemirror-tables/releases)
- [Changelog](https://github.com/ProseMirror/prosemirror-tables/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-tables/compare/v1.4.0...v1.6.4)

---
updated-dependencies:
- dependency-name: prosemirror-tables
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-12 15:47:45 -07:00
Tom Moor 516d14fe27 fix: Potential unsafe content-type check (#8673)
* fix: Potential bypass of content-type check

* Include extra available chars
2025-03-12 12:39:41 +00:00
dependabot[bot] 70268a73df chore(deps): bump @babel/runtime from 7.26.9 to 7.26.10 (#8672)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.26.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 20:09:08 -07:00
dependabot[bot] 148be1025f chore(deps): bump @babel/helpers from 7.26.9 to 7.26.10 (#8671)
Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.26.9 to 7.26.10.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.26.10/packages/babel-helpers)

---
updated-dependencies:
- dependency-name: "@babel/helpers"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-11 20:08:44 -07:00
Tom Moor 2a17ac1908 chore: Upgrade prismjs (#8670) 2025-03-12 02:36:31 +00:00
Tom Moor a70a67235d fix: First item in list must be a paragraph (#8632)
closes #8611

closes #8216
2025-03-11 18:56:17 -07:00
Tom Moor ed5bb8f8d9 fix: Inline code converts to block on paste from remote source (#8669) 2025-03-11 18:55:59 -07:00
dependabot[bot] a7731d9963 chore(deps-dev): bump discord-api-types from 0.37.102 to 0.37.119 (#8659)
Bumps [discord-api-types](https://github.com/discordjs/discord-api-types) from 0.37.102 to 0.37.119.
- [Release notes](https://github.com/discordjs/discord-api-types/releases)
- [Changelog](https://github.com/discordjs/discord-api-types/blob/main/CHANGELOG.md)
- [Commits](https://github.com/discordjs/discord-api-types/compare/0.37.102...0.37.119)

---
updated-dependencies:
- dependency-name: discord-api-types
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:56 -07:00
dependabot[bot] 6f5e0b70bc chore(deps-dev): bump terser from 5.37.0 to 5.39.0 (#8660)
Bumps [terser](https://github.com/terser/terser) from 5.37.0 to 5.39.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.37.0...v5.39.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:46 -07:00
dependabot[bot] 856467fa0c chore(deps): bump prosemirror-view from 1.37.1 to 1.38.1 (#8661)
Bumps [prosemirror-view](https://github.com/prosemirror/prosemirror-view) from 1.37.1 to 1.38.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-view/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-view/compare/1.37.1...1.38.1)

---
updated-dependencies:
- dependency-name: prosemirror-view
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:32 -07:00
dependabot[bot] 280ec17f63 chore(deps): bump @types/form-data from 2.5.0 to 2.5.2 (#8662)
Bumps [@types/form-data](https://github.com/DefinitelyTyped/DefinitelyTyped) from 2.5.0 to 2.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits)

---
updated-dependencies:
- dependency-name: "@types/form-data"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 20:07:05 -07:00
Tom Moor 84b48167cb chore: Bump @koa/bull-board (#8655) 2025-03-09 01:39:57 +00:00
Tom Moor c6f90b7647 chore: Bump dd-trace (#8654) 2025-03-09 01:29:17 +00:00
dependabot[bot] 42865b64d6 chore(deps): bump axios from 1.7.9 to 1.8.2 (#8653)
Bumps [axios](https://github.com/axios/axios) from 1.7.9 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.9...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-08 17:18:31 -08:00
Tom Moor e5b5cbaab7 Revert "chore: Upgrade path-to-regexp (#8636)" (#8652)
This reverts commit 58c4a486f7.
2025-03-08 07:18:10 -08:00
Tom Moor 463398e2c7 tom/misc-fixes (#8650) 2025-03-08 03:42:19 +00:00
Tom Moor 98c9af53c4 fix: recent searches appearing over dropdown options on search page (#8640)
* fix: Various UX issues with search filters

* Tighted search filters display
2025-03-06 05:27:57 -08:00
Tom Moor f0864b5876 fix: Add more tldraw url support (#8638) 2025-03-06 03:02:40 +00:00
Tom Moor c89535426b chore: Upgrade i18next-parser (#8637) 2025-03-06 01:40:28 +00:00
Tom Moor 58c4a486f7 chore: Upgrade path-to-regexp (#8636)
* chore: Upgrade koa-router

* chore: Upgrade dd-trace
2025-03-05 13:36:30 -08:00
Hemachandar d5462a92c8 fix: Skip unsubscribing when user has access to document (#8631)
* fix: Skip unsubscribing when user has access to document

* better checks
2025-03-04 19:26:13 -08:00
Hemachandar 7a90a909b3 Prevent duplicate emails when user has existing access to a document. (#8263)
* check user has higher access

* membershipId column

* handle document shared email

* fix and cleanup

* tests

* jsdoc

* event changeset

* check collection permission

* change date in migration filename

* review

* rename migration filename to today

* required group, jsdoc
2025-03-04 17:56:44 -08:00
Hemachandar 189ad30138 fix: Skip auto creating subscriptions when user/group is added to a document (#8630) 2025-03-04 16:58:20 -08:00
Hemachandar feb412b1fb fix: Filter archived collections in start view selection (#8629) 2025-03-04 15:26:50 -08:00
YouLL d551a1a10b feat: collection mentions (#8529)
* feat: init collection mention

* refactor: dedicated search helper function for collection mentions

* feat: add test for collection search function helper

* feat: parseCollectionSlug

* feat: isCollectionUrl

* feat: add collection mention to paste handler

* fix: update translation of mention keyboard shortcut

* fix: keyboard shortcut mention label

* fix: missing teamId in search helper functioN

* chore: update translations

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-03-03 19:03:27 -08:00
Tom Moor 2a3ea1254c Allow links in code marks (#8625) 2025-03-03 18:55:22 -08:00
Tom Moor ddfd1b70e5 fix: Allow setting revision name to null (#8626) 2025-03-04 00:44:17 +00:00
dependabot[bot] a9b18ccf14 chore(deps-dev): bump @types/react-avatar-editor from 13.0.3 to 13.0.4 (#8619)
Bumps [@types/react-avatar-editor](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-avatar-editor) from 13.0.3 to 13.0.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-avatar-editor)

---
updated-dependencies:
- dependency-name: "@types/react-avatar-editor"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 16:43:59 -08:00
dependabot[bot] 6d3b35ef6c chore(deps): bump the aws group with 5 updates (#8618)
Bumps the aws group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.750.0` | `3.758.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.750.0` | `3.758.0` |
| [@aws-sdk/s3-presigned-post](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-presigned-post) | `3.750.0` | `3.758.0` |
| [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) | `3.750.0` | `3.758.0` |
| [@aws-sdk/signature-v4-crt](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/signature-v4-crt) | `3.750.0` | `3.758.0` |


Updates `@aws-sdk/client-s3` from 3.750.0 to 3.758.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.758.0/clients/client-s3)

Updates `@aws-sdk/lib-storage` from 3.750.0 to 3.758.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.758.0/lib/lib-storage)

Updates `@aws-sdk/s3-presigned-post` from 3.750.0 to 3.758.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-presigned-post/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.758.0/packages/s3-presigned-post)

Updates `@aws-sdk/s3-request-presigner` from 3.750.0 to 3.758.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.758.0/packages/s3-request-presigner)

Updates `@aws-sdk/signature-v4-crt` from 3.750.0 to 3.758.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/signature-v4-crt/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.758.0/packages/signature-v4-crt)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/lib-storage"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-presigned-post"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
- dependency-name: "@aws-sdk/signature-v4-crt"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: aws
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:27:00 -08:00
dependabot[bot] c7e96da95a chore(deps-dev): bump @types/react-color from 3.0.12 to 3.0.13 (#8621)
Bumps [@types/react-color](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-color) from 3.0.12 to 3.0.13.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-color)

---
updated-dependencies:
- dependency-name: "@types/react-color"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:26:29 -08:00
dependabot[bot] 3270ba7fa6 chore(deps): bump socket.io-client from 4.8.0 to 4.8.1 (#8620)
Bumps [socket.io-client](https://github.com/socketio/socket.io) from 4.8.0 to 4.8.1.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-client@4.8.0...socket.io-client@4.8.1)

---
updated-dependencies:
- dependency-name: socket.io-client
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 14:26:17 -08:00
Tom Moor fcff256586 fix: Apply full width from template (#8615) 2025-03-03 04:00:02 +00:00
Tom Moor 0cfe0fc05b Backporting more from enterprise (#8613) 2025-03-03 03:10:38 +00:00
Tom Moor 67b3e175ee Add useLocaleTime (#8608) 2025-03-02 20:18:08 +00:00
Tom Moor d3235250a8 perf: Move text serialization to task runner (#8589)
* perf: Move text serialization to task runner

* tsc

* test

* refactor

* fix: Restore previous default of toMarkdown behavior

* Stop writing text to revisions
2025-03-02 08:21:50 -08:00
Tom Moor 237253afdb fix: Flaky test ordered event expectations (#8607) 2025-03-02 13:21:25 +00:00
Tom Moor 82cdebfb66 Add name column to revisions (#8603)
* fix: Flaky test

* Migration, model interface

* Add policies to revisions

* Add revisions.update endpoint

* tests

* lint
2025-03-02 05:07:30 -08:00
Tom Moor bed0bf9ec8 feat: Add filtering to shared links admin table (#8602)
* Add query parameter to shares.list

* Add filter on shared links table

* Additional test
2025-03-01 22:22:15 +00:00
Tom Moor 4573b3fea2 fix: Danger button focus ring (#8601) 2025-03-01 21:44:38 +00:00
Tom Moor 110e489c30 fix: Reposition TOC for printing (#8600)
* Reposition TOC for printing

* refactor
2025-03-01 13:11:52 -08:00
Tom Moor b34dd138cd fix: Creates a gap cursor position between tables positioned next to each other (#8599) 2025-03-01 13:11:42 -08:00
Tom Moor 3b1ce063bf Default comments to 'Order in doc' (#8597) 2025-03-01 11:21:31 -08:00
Tom Moor b1d8acbad1 feat: Add 'Search in document' to command menu, add shortcut (#8596) 2025-03-01 10:45:31 -08:00
Tom Moor ae05520a25 feat: Add query parameter to collections.list (#8595) 2025-03-01 09:02:17 -08:00
Tom Moor 6e30bf3c64 fix: Current user presence in documents is incorrect (#8593)
* fix: Own presence in documents is not correct

* docs
2025-03-01 08:28:19 -08:00
Tom Moor 775b038359 fix: Members table always fades in (#8594)
* PeopleTable -> MemberTable

* fix: Members table always fades in
2025-03-01 08:28:09 -08:00
133 changed files with 2668 additions and 1334 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
"setupFilesAfterEnv": ["<rootDir>/server/test/setup.ts"],
"globalSetup": "<rootDir>/server/test/globalSetup.js",
"globalTeardown": "<rootDir>/server/test/globalTeardown.js",
"testEnvironment": "node"
"testEnvironment": "node",
"testTimeout": 10000
},
{
"displayName": "app",
+2
View File
@@ -683,6 +683,7 @@ export const searchInDocument = createAction({
name: ({ t }) => t("Search in document"),
analyticsName: "Search document",
section: ActiveDocumentSection,
shortcut: [`Meta+/`],
icon: <SearchIcon />,
visible: ({ stores, activeDocumentId }) => {
if (!activeDocumentId) {
@@ -1210,6 +1211,7 @@ export const rootDocumentActions = [
unpublishDocument,
subscribeDocument,
unsubscribeDocument,
searchInDocument,
duplicateDocument,
leaveDocument,
moveTemplateToWorkspace,
+2
View File
@@ -2,6 +2,8 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+2 -1
View File
@@ -3,6 +3,7 @@ import { ArrowIcon, BackIcon } from "outline-icons";
import * as React from "react";
import styled, { css, useTheme } from "styled-components";
import { s, ellipsis } from "@shared/styles";
import { normalizeKeyDisplay } from "@shared/utils/keyboard";
import Flex from "~/components/Flex";
import Key from "~/components/Key";
import Text from "~/components/Text";
@@ -70,7 +71,7 @@ function CommandBarItem(
""
)}
{sc.split("+").map((key) => (
<Key key={key}>{key}</Key>
<Key key={key}>{normalizeKeyDisplay(key)}</Key>
))}
</React.Fragment>
))}
+3
View File
@@ -161,6 +161,9 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&:focus-visible {
color: ${props.theme.accentText};
background: ${props.dangerous ? props.theme.danger : props.theme.accent};
outline-color: ${
props.dangerous ? props.theme.danger : props.theme.accent
};
box-shadow: none;
cursor: var(--pointer);
@@ -73,15 +73,13 @@ function EditableTitle(
return;
}
if (document) {
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
try {
await onSubmit(trimmedValue);
setOriginalValue(trimmedValue);
} catch (error) {
setValue(originalValue);
toast.error(error.message);
throw error;
}
},
[originalValue, value, onCancel, onSubmit]
@@ -127,7 +125,10 @@ function EditableTitle(
/>
</form>
) : (
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
<span
onDoubleClick={canUpdate ? handleDoubleClick : undefined}
className={rest.className}
>
{value}
</span>
)}
+2 -2
View File
@@ -234,7 +234,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px + 8px);
background: ${s("divider")};
mix-blend-mode: multiply;
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
@@ -255,7 +255,7 @@ const lineStyle = css`
width: 1px;
height: calc(50% - 14px);
background: ${s("divider")};
mix-blend-mode: multiply;
mix-blend-mode: ${(props) => (props.theme.isDark ? "lighten" : "multiply")};
z-index: 1;
}
+13 -16
View File
@@ -23,7 +23,6 @@ type Props = {
options: TFilterOption[];
selectedKeys: (string | null | undefined)[];
defaultLabel?: string;
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
@@ -35,7 +34,6 @@ const FilterOptions = ({
options,
selectedKeys = [],
defaultLabel = "Filter options",
selectedPrefix = "",
className,
onSelect,
showFilter,
@@ -54,9 +52,7 @@ const FilterOptions = ({
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems
.map((selected) => `${selectedPrefix} ${selected.label}`)
.join(", ")
? selectedItems.map((selected) => selected.label).join(", ")
: "";
const renderItem = React.useCallback(
@@ -70,7 +66,7 @@ const FilterOptions = ({
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.icon}
{option.note ? (
<LabelWithNote>
{option.label}
@@ -163,10 +159,16 @@ const FilterOptions = ({
const showFilterInput = showFilter || options.length > 10;
return (
<div>
<>
<MenuButton {...menu}>
{(props) => (
<StyledButton {...props} className={className} neutral disclosure>
<StyledButton
{...props}
className={className}
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
neutral
disclosure
>
{selectedItems.length ? selectedLabel : defaultLabel}
</StyledButton>
)}
@@ -193,7 +195,7 @@ const FilterOptions = ({
/>
)}
</ContextMenu>
</div>
</>
);
};
@@ -231,6 +233,7 @@ const SearchInput = styled(Input)`
border-radius: 0;
border-bottom: 1px solid ${s("divider")};
background: ${s("menuBackground")};
margin: 0;
}
${NativeInput} {
@@ -267,15 +270,9 @@ export const StyledButton = styled(Button)`
}
${Inner} {
line-height: 24px;
line-height: 28px;
min-height: auto;
}
`;
const Icon = styled.div`
margin-right: 8px;
width: 18px;
height: 18px;
`;
export default FilterOptions;
+11 -9
View File
@@ -33,6 +33,7 @@ export type Props = Omit<React.HTMLAttributes<HTMLAnchorElement>, "title"> & {
small?: boolean;
/** Whether to enable keyboard navigation */
keyboardNavigation?: boolean;
ellipsis?: boolean;
};
const ListItem = (
@@ -45,6 +46,7 @@ const ListItem = (
border,
to,
keyboardNavigation,
ellipsis,
...rest
}: Props,
ref: React.RefObject<HTMLAnchorElement>
@@ -83,7 +85,9 @@ const ListItem = (
column={!compact}
$selected={selected}
>
<Heading $small={small}>{title}</Heading>
<Heading $small={small} $ellipsis={ellipsis}>
{title}
</Heading>
{subtitle && (
<Subtitle $small={small} $selected={selected}>
{subtitle}
@@ -105,7 +109,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.accent,
background: theme.sidebarActiveBackground,
}}
{...rest}
{...rovingTabIndex}
@@ -208,10 +212,10 @@ const Image = styled(Flex)`
color: ${s("text")};
`;
const Heading = styled.p<{ $small?: boolean }>`
const Heading = styled.p<{ $small?: boolean; $ellipsis?: boolean }>`
font-size: ${(props) => (props.$small ? 14 : 16)}px;
font-weight: 500;
${ellipsis()}
${(props) => (props.$ellipsis !== false ? ellipsis() : "")}
line-height: ${(props) => (props.$small ? 1.3 : 1.2)};
margin: 0;
`;
@@ -219,14 +223,13 @@ const Heading = styled.p<{ $small?: boolean }>`
const Content = styled(Flex)<{ $selected: boolean }>`
flex-direction: column;
flex-grow: 1;
color: ${(props) => (props.$selected ? props.theme.white : props.theme.text)};
color: ${s("text")};
`;
const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
margin: 0;
font-size: ${(props) => (props.$small ? 13 : 14)}px;
color: ${(props) =>
props.$selected ? props.theme.white50 : props.theme.textTertiary};
color: ${s("textTertiary")};
margin-top: -2px;
`;
@@ -234,8 +237,7 @@ export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
color: ${s("textSecondary")};
`;
export default React.forwardRef(ListItem);
+5 -69
View File
@@ -1,24 +1,7 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import { locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
import { useLocaleTime } from "~/hooks/useLocaleTime";
export type Props = {
children?: React.ReactNode;
@@ -29,59 +12,12 @@ export type Props = {
format?: Partial<Record<keyof typeof locales, string>>;
};
const LocaleTime: React.FC<Props> = ({
addSuffix,
children,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
const LocaleTime: React.FC<Props> = ({ children, ...rest }: Props) => {
const { tooltipContent, content } = useLocaleTime(rest);
return (
<Tooltip content={tooltipContent} placement="bottom">
<time dateTime={dateTime}>{children || content}</time>
<time dateTime={rest.dateTime}>{children || content}</time>
</Tooltip>
);
};
@@ -10,6 +10,7 @@ import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { CollectionValidation, DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import NudeButton from "~/components/NudeButton";
@@ -21,7 +22,6 @@ import CollectionMenu from "~/menus/CollectionMenu";
import { documentEditPath } from "~/utils/routeHelpers";
import { useDropToChangeCollection } from "../hooks/useDragAndDrop";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
import SidebarLink from "./SidebarLink";
@@ -12,6 +12,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import { DocumentValidation } from "@shared/validations";
import Collection from "~/models/Collection";
import Document from "~/models/Document";
import EditableTitle, { RefHandle } from "~/components/EditableTitle";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
@@ -28,7 +29,6 @@ import {
} from "../hooks/useDragAndDrop";
import DropCursor from "./DropCursor";
import DropToImport from "./DropToImport";
import EditableTitle, { RefHandle } from "./EditableTitle";
import Folder from "./Folder";
import Relative from "./Relative";
import { SidebarContextType, useSidebarContext } from "./SidebarContext";
+41 -4
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -12,7 +12,11 @@ import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -40,7 +44,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -49,8 +53,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
);
@@ -119,6 +125,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -146,7 +180,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
return;
}
if (!documentId) {
+47 -1
View File
@@ -20,8 +20,9 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
@@ -166,6 +167,51 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
+83
View File
@@ -0,0 +1,83 @@
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import useUserLocale from "~/hooks/useUserLocale";
let callbacks: (() => void)[] = [];
// This is a shared timer that fires every minute, used for
// updating all Time components across the page all at once.
setInterval(() => {
callbacks.forEach((cb) => cb());
}, 1000 * 60);
function eachMinute(fn: () => void) {
callbacks.push(fn);
return () => {
callbacks = callbacks.filter((cb) => cb !== fn);
};
}
export type Props = {
dateTime: string;
addSuffix?: boolean;
shorten?: boolean;
relative?: boolean;
format?: Partial<Record<keyof typeof locales, string>>;
};
export const useLocaleTime = ({
addSuffix,
dateTime,
shorten,
format,
relative,
}: Props) => {
const userLocale = useUserLocale();
const dateFormatLong: Record<string, string> = {
en_US: "MMMM do, yyyy h:mm a",
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
};
const formatLocaleLong =
(userLocale ? dateFormatLong[userLocale] : undefined) ??
"MMMM do, yyyy h:mm a";
// @ts-expect-error fallback to formatLocaleLong
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
const callback = React.useRef<() => void>();
React.useEffect(() => {
callback.current = eachMinute(() => {
setMinutesMounted((state) => ++state);
});
return () => {
if (callback.current) {
callback.current?.();
}
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(date, formatLocale, {
locale,
});
return {
content,
tooltipContent,
};
};
+5
View File
@@ -92,6 +92,11 @@ export default class Collection extends ParanoidModel {
@observable
archivedBy?: User;
@computed
get searchContent(): string {
return this.name;
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
+2 -1
View File
@@ -188,9 +188,10 @@ export default class Document extends ArchivableModel implements Searchable {
@observable
collaboratorIds: string[];
@observable
@Relation(() => User)
createdBy: User | undefined;
@Relation(() => User)
@observable
updatedBy: User | undefined;
+2
View File
@@ -7,6 +7,7 @@ import {
import { bytesToHumanReadable } from "@shared/utils/files";
import User from "./User";
import Model from "./base/Model";
import Relation from "./decorators/Relation";
class FileOperation extends Model {
static modelName = "FileOperation";
@@ -27,6 +28,7 @@ class FileOperation extends Model {
format: FileOperationFormat;
@Relation(() => User)
user: User;
@computed
+5
View File
@@ -4,6 +4,7 @@ import { isRTL } from "@shared/utils/rtl";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
class Revision extends Model {
@@ -19,6 +20,10 @@ class Revision extends Model {
/** The document title when the revision was created */
title: string;
/** An optional name for the revision */
@Field
name: string | null;
/** Prosemirror data of the content when revision was created */
data: ProsemirrorData;
+8 -2
View File
@@ -1,12 +1,13 @@
import { observable } from "mobx";
import { computed, observable } from "mobx";
import Collection from "./Collection";
import Document from "./Document";
import User from "./User";
import Model from "./base/Model";
import Field from "./decorators/Field";
import Relation from "./decorators/Relation";
import { Searchable } from "./interfaces/Searchable";
class Share extends Model {
class Share extends Model implements Searchable {
static modelName = "Share";
@Field
@@ -65,6 +66,11 @@ class Share extends Model {
/** The user that shared the document. */
@Relation(() => User, { onDelete: "null" })
createdBy: User;
@computed
get searchContent(): string[] {
return [this.document?.title ?? this.documentTitle];
}
}
export default Share;
@@ -174,6 +174,7 @@ class DocumentScene extends React.Component<Props> {
if (template instanceof Document) {
this.props.document.templateId = template.id;
this.props.document.fullWidth = template.fullWidth;
}
if (!this.title) {
@@ -551,6 +552,11 @@ class DocumentScene extends React.Component<Props> {
>
<Notices document={document} readOnly={readOnly} />
{showContents && (
<PrintContentsContainer>
<Contents />
</PrintContentsContainer>
)}
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -665,6 +671,19 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
justify-self: ${({ position }: ContentsContainerProps) =>
position === TOCPosition.Left ? "end" : "start"};
`};
@media print {
display: none;
}
`;
const PrintContentsContainer = styled.div`
display: none;
margin: 0 -12px;
@media print {
display: block;
}
`;
type EditorContainerProps = {
@@ -99,7 +99,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
});
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
presence.updateFromAwarenessChangeEvent(documentId, event);
presence.updateFromAwarenessChangeEvent(
documentId,
provider.awareness.clientID,
event
);
event.states.forEach(({ user, scrollY }) => {
if (user) {
+1 -1
View File
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
items: [
{
shortcut: "@",
label: t("Mention user or document"),
label: t("Mention users and more"),
},
{
shortcut: ":",
+7 -10
View File
@@ -8,7 +8,7 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { v4 as uuidv4 } from "uuid";
import { Pagination } from "@shared/constants";
import { hover, hideScrollbars } from "@shared/styles";
import { hideScrollbars } from "@shared/styles";
import {
DateFilter as TDateFilter,
StatusFilter as TStatusFilter,
@@ -60,10 +60,10 @@ function Search(props: Props) {
routeMatch.params.term ?? params.get("query") ?? ""
).trim();
const query = decodedQuery !== "" ? decodedQuery : undefined;
const collectionId = params.get("collectionId") ?? undefined;
const userId = params.get("userId") ?? undefined;
const collectionId = params.get("collectionId") ?? "";
const userId = params.get("userId") ?? "";
const documentId = params.get("documentId") ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? undefined;
const dateFilter = (params.get("dateFilter") as TDateFilter) ?? "";
const statusFilter = params.getAll("statusFilter")?.length
? (params.getAll("statusFilter") as TStatusFilter[])
: [TStatusFilter.Published, TStatusFilter.Draft];
@@ -375,27 +375,24 @@ const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
const Filters = styled(Flex)`
margin-bottom: 12px;
opacity: 0.85;
transition: opacity 100ms ease-in-out;
overflow-y: hidden;
overflow-x: auto;
padding: 8px 0;
height: 28px;
gap: 8px;
${hideScrollbars()}
${breakpoint("tablet")`
padding: 0;
`};
&: ${hover} {
opacity: 1;
}
`;
const SearchTitlesFilter = styled(Switch)`
white-space: nowrap;
margin-left: 8px;
margin-top: 2px;
margin-top: 4px;
font-size: 14px;
font-weight: 400;
`;
@@ -21,13 +21,13 @@ function CollectionFilter(props: Props) {
const collectionOptions = collections.orderedData.map((collection) => ({
key: collection.id,
label: collection.name,
icon: <CollectionIcon collection={collection} size={18} />,
icon: <CollectionIcon collection={collection} size={24} />,
}));
return [
{
key: "",
label: t("Any collection"),
icon: <SVGCollectionIcon size={18} />,
icon: <SVGCollectionIcon size={24} />,
},
...collectionOptions,
];
@@ -39,7 +39,6 @@ function CollectionFilter(props: Props) {
selectedKeys={[collectionId]}
onSelect={onSelect}
defaultLabel={t("Any collection")}
selectedPrefix={`${t("Collection")}:`}
showFilter
/>
);
+1 -1
View File
@@ -16,7 +16,7 @@ const DateFilter = ({ dateFilter, onSelect }: Props) => {
() => [
{
key: "",
label: t("Any time"),
label: t("All time"),
},
{
key: "day",
@@ -19,7 +19,7 @@ export function DocumentFilter(props: Props) {
<div>
<Tooltip content={t("Remove document filter")}>
<StyledButton onClick={props.onClick} icon={<CloseIcon />} neutral>
{props.document.title}
{props.document.titleWithDefault}
</StyledButton>
</Tooltip>
</div>
@@ -51,7 +51,9 @@ const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${s("textTertiary")};
&:hover {
&:focus,
&:${hover} {
opacity: 1;
color: ${s("text")};
}
`;
@@ -61,17 +63,11 @@ const RecentSearch = styled(Link)`
justify-content: space-between;
color: ${s("textSecondary")};
cursor: var(--pointer);
padding: 1px 4px;
padding: 1px 8px;
border-radius: 4px;
position: relative;
line-height: 24px;
font-size: 14px;
&:before {
content: "·";
color: ${s("textTertiary")};
position: absolute;
left: -8px;
}
margin: 0 -8px;
&:focus-visible {
outline: none;
@@ -59,7 +59,7 @@ const Heading = styled.h2`
font-size: 14px;
line-height: 1.5;
color: ${s("textSecondary")};
margin-bottom: 0;
margin: 12px 0 0;
`;
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
+4 -5
View File
@@ -25,13 +25,13 @@ function UserFilter(props: Props) {
const userOptions = users.all.map((user) => ({
key: user.id,
label: user.name,
icon: <Avatar model={user} size={AvatarSize.Small} />,
icon: <StyledAvatar model={user} size={AvatarSize.Small} />,
}));
return [
{
key: "",
label: t("Any author"),
icon: <NoAuthor size={20} />,
icon: <UserIcon size={20} />,
},
...userOptions,
];
@@ -43,7 +43,6 @@ function UserFilter(props: Props) {
selectedKeys={[userId]}
onSelect={onSelect}
defaultLabel={t("Any author")}
selectedPrefix={`${t("Author")}:`}
fetchQuery={users.fetchPage}
fetchQueryOptions={fetchQueryOptions}
showFilter
@@ -51,8 +50,8 @@ function UserFilter(props: Props) {
);
}
const NoAuthor = styled(UserIcon)`
margin-left: -2px;
const StyledAvatar = styled(Avatar)`
margin: 2px;
`;
export default observer(UserFilter);
+42 -2
View File
@@ -3,10 +3,11 @@ import { observer } from "mobx-react";
import { GlobeIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useHistory, useLocation } from "react-router-dom";
import { toast } from "sonner";
import { ConditionalFade } from "~/components/Fade";
import Heading from "~/components/Heading";
import InputSearch from "~/components/InputSearch";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
@@ -16,17 +17,22 @@ import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { useTableRequest } from "~/hooks/useTableRequest";
import { SharesTable } from "./components/SharesTable";
import { StickyFilters } from "./components/StickyFilters";
function Shares() {
const team = useCurrentTeam();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const { shares, auth } = useStores();
const canShareDocuments = auth.team && auth.team.sharing;
const can = usePolicy(team);
const params = useQuery();
const [query, setQuery] = React.useState("");
const reqParams = React.useMemo(
() => ({
query: params.get("query") || undefined,
sort: params.get("sort") || "createdAt",
direction: (params.get("direction") || "desc").toUpperCase() as
| "ASC"
@@ -44,18 +50,44 @@ function Shares() {
);
const { data, error, loading, next } = useTableRequest({
data: shares.orderedData,
data: shares.findByQuery(reqParams.query ?? ""),
sort,
reqFn: shares.fetchPage,
reqParams,
});
const updateParams = React.useCallback(
(name: string, value: string) => {
if (value) {
params.set(name, value);
} else {
params.delete(name);
}
history.replace({
pathname: location.pathname,
search: params.toString(),
});
},
[params, history, location.pathname]
);
const handleSearch = React.useCallback((event) => {
const { value } = event.target;
setQuery(value);
}, []);
React.useEffect(() => {
if (error) {
toast.error(t("Could not load shares"));
}
}, [t, error]);
React.useEffect(() => {
const timeout = setTimeout(() => updateParams("query", query), 250);
return () => clearTimeout(timeout);
}, [query, updateParams]);
return (
<Scene title={t("Shared Links")} icon={<GlobeIcon />} wide>
<Heading>{t("Shared Links")}</Heading>
@@ -83,6 +115,14 @@ function Shares() {
</Trans>
</Text>
<StickyFilters gap={8}>
<InputSearch
short
value={query}
placeholder={`${t("Filter")}`}
onChange={handleSearch}
/>
</StickyFilters>
<ConditionalFade animate={!data}>
<SharesTable
data={data ?? []}
@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { unicodeCLDRtoBCP47 } from "@shared/utils/date";
import Share from "~/models/Share";
import { Avatar } from "~/components/Avatar";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { HEADER_HEIGHT } from "~/components/Header";
import {
@@ -46,10 +46,10 @@ export function SharesTable({ data, canManage, ...rest }: Props) {
accessor: (share) => share.createdBy,
sortable: false,
component: (share) => (
<Flex align="center" gap={4}>
<Flex align="center" gap={8}>
{share.createdBy && (
<>
<Avatar model={share.createdBy} />
<Avatar model={share.createdBy} size={AvatarSize.Small} />
{share.createdBy.name}
</>
)}
+3 -1
View File
@@ -69,7 +69,9 @@ export default class CollectionsStore extends Store<Collection> {
*/
@computed
get nonPrivate(): Collection[] {
return this.all.filter((collection) => !collection.isPrivate);
return this.all.filter(
(collection) => collection.isActive && !collection.isPrivate
);
}
/**
+42 -8
View File
@@ -14,17 +14,16 @@ export default class PresenceStore {
@observable
data: Map<string, DocumentPresence> = new Map();
timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
offlineTimeout = 30000;
private rootStore: RootStore;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
}
// called when a user leaves the document
/**
* Removes a user from the presence store
*
* @param documentId ID of the document to remove the user from
* @param userId ID of the user to remove
*/
@action
public leave(documentId: string, userId: string) {
const existing = this.data.get(documentId);
@@ -34,8 +33,16 @@ export default class PresenceStore {
}
}
/**
* Updates the presence store based on an awareness event from YJS
*
* @param documentId ID of the document the event is for
* @param clientId ID of the client the event is for
* @param event The awareness event
*/
public updateFromAwarenessChangeEvent(
documentId: string,
clientId: number,
event: AwarenessChangeEvent
) {
const presence = this.data.get(documentId);
@@ -45,7 +52,13 @@ export default class PresenceStore {
event.states.forEach((state) => {
const { user, cursor } = state;
if (user && this.rootStore.auth.currentUserId !== user.id) {
// To avoid loops we only want to update the presence for the current user
// if it is also the current client.
const isCurrentUser = this.rootStore.auth.currentUserId === user?.id;
const isCurrentClient = clientId === state.clientId;
if (user && (!isCurrentUser || !isCurrentClient)) {
this.update(documentId, user.id, !!cursor);
existingUserIds = existingUserIds.filter((id) => id !== user.id);
}
@@ -56,6 +69,14 @@ export default class PresenceStore {
});
}
/**
* Updates the presence store to indicate that a user is present in a document
* and then removes the user after a timeout of inactivity.
*
* @param documentId ID of the document to update
* @param userId ID of the user to update
* @param isEditing Whether the user is "editing" the document
*/
public touch(documentId: string, userId: string, isEditing: boolean) {
const id = `${documentId}-${userId}`;
let timeout = this.timeouts.get(id);
@@ -73,6 +94,13 @@ export default class PresenceStore {
this.timeouts.set(id, timeout);
}
/**
* Updates the presence store to indicate that a user is present in a document.
*
* @param documentId ID of the document to update
* @param userId ID of the user to update
* @param isEditing Whether the user is "editing" the document
*/
@action
private update(documentId: string, userId: string, isEditing: boolean) {
const presence = this.data.get(documentId) || new Map();
@@ -95,4 +123,10 @@ export default class PresenceStore {
public clear() {
this.data.clear();
}
private timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
private offlineTimeout = 30000;
private rootStore: RootStore;
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { PaginationParams } from "~/types";
import { client } from "~/utils/ApiClient";
export default class RevisionsStore extends Store<Revision> {
actions = [RPCAction.List, RPCAction.Info];
actions = [RPCAction.List, RPCAction.Update, RPCAction.Info];
constructor(rootStore: RootStore) {
super(rootStore, Revision);
+24 -1
View File
@@ -206,8 +206,31 @@ export type WebsocketEvent =
| WebsocketEntitiesEvent
| WebsocketCommentReactionEvent;
type CursorPosition = {
type: {
client: number;
clock: number;
};
tname: string | null;
item: {
client: number;
clock: number;
};
assoc: number;
};
type Cursor = {
anchor: CursorPosition;
head: CursorPosition;
};
export type AwarenessChangeEvent = {
states: { user?: { id: string }; cursor: any; scrollY: number | undefined }[];
states: {
clientId: number;
user?: { id: string };
cursor: Cursor;
scrollY: number | undefined;
}[];
};
export const EmptySelectValue = "__empty__";
+19 -18
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.750.0",
"@aws-sdk/lib-storage": "3.750.0",
"@aws-sdk/s3-presigned-post": "3.750.0",
"@aws-sdk/s3-request-presigner": "3.750.0",
"@aws-sdk/signature-v4-crt": "^3.750.0",
"@aws-sdk/client-s3": "3.758.0",
"@aws-sdk/lib-storage": "3.758.0",
"@aws-sdk/s3-presigned-post": "3.758.0",
"@aws-sdk/s3-request-presigner": "3.758.0",
"@aws-sdk/signature-v4-crt": "^3.758.0",
"@babel/core": "^7.26.9",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -61,8 +61,8 @@
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
"@bull-board/api": "^6.7.10",
"@bull-board/koa": "^6.7.10",
"@css-inline/css-inline-wasm": "^0.14.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
@@ -88,7 +88,7 @@
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
@@ -108,7 +108,7 @@
"crypto-js": "^4.2.0",
"datadog-metrics": "^0.11.2",
"date-fns": "^3.6.0",
"dd-trace": "^3.58.0",
"dd-trace": "^5.40.0",
"diff": "^5.2.0",
"dotenv": "^16.4.7",
"email-providers": "^1.14.0",
@@ -184,9 +184,9 @@
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.4.0",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
"prosemirror-view": "^1.37.1",
"prosemirror-view": "^1.38.1",
"query-string": "^7.1.3",
"randomstring": "1.3.1",
"rate-limiter-flexible": "^2.4.2",
@@ -225,7 +225,7 @@
"slug": "^5.3.0",
"slugify": "^1.6.6",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.0",
"socket.io-client": "^4.8.1",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.1",
"stoppable": "^1.1.0",
@@ -299,8 +299,8 @@
"@types/quoted-printable": "^1.0.2",
"@types/randomstring": "^1.3.0",
"@types/react": "^17.0.34",
"@types/react-avatar-editor": "^13.0.3",
"@types/react-color": "^3.0.12",
"@types/react-avatar-editor": "^13.0.4",
"@types/react-color": "^3.0.13",
"@types/react-dom": "^17.0.11",
"@types/react-helmet": "^6.1.11",
"@types/react-portal": "^4.0.7",
@@ -331,7 +331,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.102",
"discord-api-types": "^0.37.119",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.8.0",
@@ -344,7 +344,7 @@
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^8.0.3",
"i18next-parser": "^7.9.0",
"i18next-parser": "^8.13.0",
"jest-cli": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
@@ -355,7 +355,7 @@
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.37.0",
"terser": "^5.39.0",
"typescript": "^5.7.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
@@ -368,7 +368,8 @@
"node-fetch": "^2.7.0",
"js-yaml": "^3.14.1",
"qs": "6.9.7",
"rollup": "^4.5.1"
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.82.0"
}
@@ -1,10 +1,8 @@
import isEqual from "fast-deep-equal";
import uniq from "lodash/uniq";
import { Node } from "prosemirror-model";
import { yDocToProsemirrorJSON } from "y-prosemirror";
import * as Y from "yjs";
import { ProsemirrorData } from "@shared/types";
import { schema, serializer } from "@server/editor";
import Logger from "@server/logging/Logger";
import { Document, Event } from "@server/models";
import { sequelize } from "@server/storage/database";
@@ -45,8 +43,6 @@ export default async function documentCollaborativeUpdater({
const state = Y.encodeStateAsUpdate(ydoc);
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
const node = Node.fromJSON(schema, content);
const text = serializer.serialize(node, undefined);
const isUnchanged = isEqual(document.content, content);
const lastModifiedById =
sessionCollaboratorIds[sessionCollaboratorIds.length - 1] ??
@@ -72,7 +68,6 @@ export default async function documentCollaborativeUpdater({
await document.update(
{
text,
content,
state: Buffer.from(state),
lastModifiedById,
+54 -48
View File
@@ -1,8 +1,9 @@
import { Optional } from "utility-types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { ProsemirrorHelper as SharedProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import { TextHelper } from "@shared/utils/TextHelper";
import { Document, Event, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { APIContext } from "@server/types";
type Props = Optional<
@@ -81,53 +82,58 @@ export default async function documentCreator({
}
}
const document = await Document.create(
{
id,
urlId,
parentDocumentId,
editorVersion,
collectionId,
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: user.id,
createdById: user.id,
template,
templateId,
publishedAt,
importId,
sourceMetadata,
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
icon: templateDocument ? templateDocument.icon : icon,
color: templateDocument ? templateDocument.color : color,
title:
title ??
(templateDocument
? template
? templateDocument.title
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
: ""),
text:
text ??
(templateDocument
? template
? templateDocument.text
: TextHelper.replaceTemplateVariables(templateDocument.text, user)
: ""),
content: templateDocument
? ProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(templateDocument),
user
)
: content,
state,
},
{
silent: !!createdAt,
transaction,
}
);
const titleWithReplacements =
title ??
(templateDocument
? template
? templateDocument.title
: TextHelper.replaceTemplateVariables(templateDocument.title, user)
: "");
const contentWithReplacements = text
? ProsemirrorHelper.toProsemirror(text).toJSON()
: templateDocument
? template
? templateDocument.content
: SharedProsemirrorHelper.replaceTemplateVariables(
await DocumentHelper.toJSON(templateDocument),
user
)
: content;
const document = Document.build({
id,
urlId,
parentDocumentId,
editorVersion,
collectionId,
teamId: user.teamId,
createdAt,
updatedAt: updatedAt ?? createdAt,
lastModifiedById: user.id,
createdById: user.id,
template,
templateId,
publishedAt,
importId,
sourceMetadata,
fullWidth: fullWidth ?? templateDocument?.fullWidth,
icon: icon ?? templateDocument?.icon,
color: color ?? templateDocument?.color,
title: titleWithReplacements,
content: contentWithReplacements,
state,
});
document.text = DocumentHelper.toMarkdown(document, {
includeTitle: false,
});
await document.save({
silent: !!createdAt,
transaction,
});
await Event.create(
{
name: "documents.create",
-2
View File
@@ -52,7 +52,6 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(document),
["comment"]
),
text: document.text,
...sharedProperties,
});
@@ -86,7 +85,6 @@ export default async function documentDuplicator({
DocumentHelper.toProsemirror(childDocument),
["comment"]
),
text: childDocument.text,
...sharedProperties,
});
+6 -3
View File
@@ -122,11 +122,11 @@ export class Mailer {
sendMail = async (data: SendMailOptions): Promise<void> => {
const { transporter } = this;
if (!transporter) {
Logger.info(
if (env.isDevelopment) {
Logger.debug(
"email",
[
`Attempted to send email but no transport configured.`,
`Sending email:`,
``,
`--------------`,
`From: ${data.from.address}`,
@@ -138,6 +138,9 @@ export class Mailer {
data.text,
].join("\n")
);
}
if (!transporter) {
Logger.warn("No mail transport available");
return;
}
@@ -1,6 +1,6 @@
import * as React from "react";
import { DocumentPermission } from "@shared/types";
import { Document, UserMembership } from "@server/models";
import { Document, GroupMembership, UserMembership } from "@server/models";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
@@ -11,13 +11,14 @@ import Heading from "./components/Heading";
type InputProps = EmailProps & {
userId: string;
documentId: string;
membershipId?: string;
actorName: string;
teamUrl: string;
};
type BeforeSend = {
document: Document;
membership: UserMembership;
membership: UserMembership | GroupMembership;
};
type Props = InputProps & BeforeSend;
@@ -33,18 +34,20 @@ export default class DocumentSharedEmail extends BaseEmail<
return EmailMessageCategory.Notification;
}
protected async beforeSend({ documentId, userId }: InputProps) {
protected async beforeSend({ documentId, membershipId }: InputProps) {
if (!membershipId) {
return false;
}
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const membership = await UserMembership.findOne({
where: {
documentId,
userId,
},
});
const membership =
(await UserMembership.findByPk(membershipId)) ??
(await GroupMembership.findByPk(membershipId));
if (!membership) {
return false;
}
@@ -0,0 +1,14 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("notifications", "membershipId", {
type: Sequelize.UUID,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("notifications", "membershipId");
},
};
@@ -0,0 +1,15 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("revisions", "name", {
type: Sequelize.STRING,
allowNull: true,
});
},
async down(queryInterface) {
await queryInterface.removeColumn("revisions", "name");
},
};
+3 -1
View File
@@ -830,7 +830,9 @@ class Document extends ArchivableModel<
}
this.content = revision.content;
this.text = revision.text;
this.text = DocumentHelper.toMarkdown(revision, {
includeTitle: false,
});
this.title = revision.title;
this.icon = revision.icon;
this.color = revision.color;
+5
View File
@@ -177,6 +177,10 @@ class Notification extends Model<
@Column(DataType.UUID)
teamId: string;
@AllowNull
@Column(DataType.UUID)
membershipId: string;
@AfterCreate
static async createEvent(
model: Notification,
@@ -191,6 +195,7 @@ class Notification extends Model<
documentId: model.documentId,
collectionId: model.collectionId,
actorId: model.actorId,
membershipId: model.membershipId,
};
if (options.transaction) {
-1
View File
@@ -16,6 +16,5 @@ describe("#findLatest", () => {
await Revision.createFromDocument(document);
const revision = await Revision.findLatest(document.id);
expect(revision?.title).toBe("Changed 2");
expect(revision?.text).toBe("Content");
});
});
+17 -9
View File
@@ -15,7 +15,7 @@ import {
Length as SimpleLength,
} from "sequelize-typescript";
import type { ProsemirrorData } from "@shared/types";
import { DocumentValidation } from "@shared/validations";
import { DocumentValidation, RevisionValidation } from "@shared/validations";
import Document from "./Document";
import User from "./User";
import IdModel from "./base/IdModel";
@@ -42,6 +42,7 @@ class Revision extends IdModel<
@Column(DataType.SMALLINT)
version?: number | null;
/** The editor version at the time of the revision */
@SimpleLength({
max: 255,
msg: `editorVersion must be 255 characters or less`,
@@ -49,6 +50,7 @@ class Revision extends IdModel<
@Column
editorVersion: string;
/** The document title at the time of the revision */
@Length({
max: DocumentValidation.maxTitleLength,
msg: `Revision title must be ${DocumentValidation.maxTitleLength} characters or less`,
@@ -56,22 +58,29 @@ class Revision extends IdModel<
@Column
title: string;
/** An optional name for the revision */
@Length({
max: RevisionValidation.maxNameLength,
msg: `Revision name must be ${RevisionValidation.maxNameLength} characters or less`,
})
@Column
name: string | null;
/**
* The content of the revision as Markdown.
*
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if exporting lossy markdown.
* This column will be removed in a future migration.
* @deprecated Use `content` instead, or `DocumentHelper.toMarkdown` if
* exporting lossy markdown. This column will be removed in a future migration
* and is no longer being written.
*/
@Column(DataType.TEXT)
text: string;
/**
* The content of the revision as JSON.
*/
/** The content of the revision as JSON. */
@Column(DataType.JSONB)
content: ProsemirrorData | null;
/** An icon to use as the document icon. */
/** The icon at the time of the revision. */
@Length({
max: 50,
msg: `icon must be 50 characters or less`,
@@ -79,7 +88,7 @@ class Revision extends IdModel<
@Column
icon: string | null;
/** The color of the icon. */
/** The color at the time of the revision. */
@IsHexColor
@Column
color: string | null;
@@ -126,7 +135,6 @@ class Revision extends IdModel<
static buildFromDocument(document: Document) {
return this.build({
title: document.title,
text: document.text,
icon: document.icon,
color: document.color,
content: document.content,
+10 -2
View File
@@ -147,10 +147,15 @@ export class DocumentHelper {
* Returns the document as Markdown. This is a lossy conversion and should only be used for export.
*
* @param document The document or revision to convert
* @param options Options for the conversion
* @returns The document title and content as a Markdown string
*/
static toMarkdown(
document: Document | Revision | Collection | ProsemirrorData
document: Document | Revision | Collection | ProsemirrorData,
options?: {
/** Whether to include the document title (default: true) */
includeTitle?: boolean;
}
) {
const text = serializer
.serialize(DocumentHelper.toProsemirror(document))
@@ -165,7 +170,10 @@ export class DocumentHelper {
return text;
}
if (document instanceof Document || document instanceof Revision) {
if (
(document instanceof Document || document instanceof Revision) &&
options?.includeTitle !== false
) {
const iconType = determineIconType(document.icon);
const title = `${iconType === IconType.Emoji ? document.icon + " " : ""}${
@@ -3,7 +3,7 @@ import { MentionType, ProsemirrorData } from "@shared/types";
import { buildProseMirrorDoc } from "@server/test/factories";
import { MentionAttrs, ProsemirrorHelper } from "./ProsemirrorHelper";
describe("ProseMirrorHelper", () => {
describe("ProsemirrorHelper", () => {
describe("getNodeForMentionEmail", () => {
it("should return the paragraph node", () => {
const mentionAttrs: MentionAttrs = {
+5 -2
View File
@@ -118,10 +118,13 @@ export class ProsemirrorHelper {
/**
* Converts a plain object into a Prosemirror Node.
*
* @param data The object to parse
* @param data The ProsemirrorData object or string to parse.
* @returns The content as a Prosemirror Node
*/
static toProsemirror(data: ProsemirrorData) {
static toProsemirror(data: ProsemirrorData | string) {
if (typeof data === "string") {
return parser.parse(data);
}
return Node.fromJSON(schema, data);
}
@@ -861,6 +861,51 @@ describe("SearchHelper", () => {
});
});
describe("#searchCollectionsForUser", () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Test Collection",
});
await buildCollection({
teamId: team.id,
userId: user.id,
name: "Other Collection",
});
const results = await SearchHelper.searchCollectionsForUser(user, {
query: "test",
});
expect(results.length).toBe(1);
expect(results[0].id).toBe(collection1.id);
});
test("should return all collections when no query provided", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Alpha",
});
const collection2 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Beta",
});
const results = await SearchHelper.searchCollectionsForUser(user);
expect(results.length).toBe(2);
expect(results[0].id).toBe(collection1.id);
expect(results[1].id).toBe(collection2.id);
});
});
describe("webSearchQuery", () => {
test("should correctly sanitize query", () => {
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
+29
View File
@@ -203,6 +203,35 @@ export default class SearchHelper {
});
}
public static async searchCollectionsForUser(
user: User,
options: SearchOptions = {}
): Promise<Collection[]> {
const { limit = 15, offset = 0, query } = options;
const collectionIds = await user.collectionIds();
return Collection.findAll({
where: {
[Op.and]: query
? {
[Op.or]: [
Sequelize.literal(
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
),
],
}
: {},
id: collectionIds,
teamId: user.teamId,
},
order: [["name", "ASC"]],
replacements: { query: `%${query}%` },
limit,
offset,
});
}
public static async searchForUser(
user: User,
options: SearchOptions = {}
+1
View File
@@ -12,6 +12,7 @@ import "./fileOperation";
import "./integration";
import "./pins";
import "./reaction";
import "./revision";
import "./searchQuery";
import "./share";
import "./star";
+11
View File
@@ -0,0 +1,11 @@
import { User, Revision } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamMutable, or } from "./utils";
allow(User, ["update"], Revision, (actor, revision) =>
and(
//
or(actor.id === revision?.userId, actor.isAdmin),
isTeamMutable(actor)
)
);
+1 -1
View File
@@ -42,7 +42,7 @@ async function presentDocument(
const text =
!asData || options?.includeText
? document.text || DocumentHelper.toMarkdown(data)
? DocumentHelper.toMarkdown(data, { includeTitle: false })
: undefined;
const res: Record<string, any> = {
+1
View File
@@ -12,6 +12,7 @@ async function presentRevision(revision: Revision, diff?: string) {
id: revision.id,
documentId: revision.documentId,
title: strippedTitle,
name: revision.name,
data: await DocumentHelper.toJSON(revision),
icon: revision.icon ?? emoji,
color: revision.color,
@@ -6,21 +6,17 @@ import BaseProcessor from "./BaseProcessor";
export default class DocumentSubscriptionProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.add_user",
"documents.remove_user",
"documents.add_group",
"documents.remove_group",
];
async perform(event: DocumentUserEvent | DocumentGroupEvent) {
switch (event.name) {
case "documents.add_user":
case "documents.remove_user": {
await DocumentSubscriptionTask.schedule(event);
return;
}
case "documents.add_group":
case "documents.remove_group":
return this.handleGroup(event);
@@ -29,11 +25,6 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
}
private async handleGroup(event: DocumentGroupEvent) {
const userEventName: DocumentUserEvent["name"] =
event.name === "documents.add_group"
? "documents.add_user"
: "documents.remove_user";
await GroupUser.findAllInBatches<GroupUser>(
{
where: {
@@ -49,7 +40,7 @@ export default class DocumentSubscriptionProcessor extends BaseProcessor {
groupUsers.map((groupUser) =>
DocumentSubscriptionTask.schedule({
...event,
name: userEventName,
name: "documents.remove_user",
userId: groupUser.userId,
})
)
@@ -56,6 +56,7 @@ export default class EmailsProcessor extends BaseProcessor {
to: notification.user.email,
userId: notification.userId,
documentId: notification.documentId,
membershipId: notification.membershipId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
@@ -2,6 +2,7 @@ import isEqual from "fast-deep-equal";
import revisionCreator from "@server/commands/revisionCreator";
import { Revision, Document, User } from "@server/models";
import { DocumentEvent, RevisionEvent, Event } from "@server/types";
import DocumentUpdateTextTask from "../tasks/DocumentUpdateTextTask";
import BaseProcessor from "./BaseProcessor";
export default class RevisionsProcessor extends BaseProcessor {
@@ -36,6 +37,8 @@ export default class RevisionsProcessor extends BaseProcessor {
return;
}
await DocumentUpdateTextTask.schedule(event);
const user = await User.findByPk(event.actorId, {
paranoid: false,
rejectOnEmpty: true,
@@ -10,7 +10,7 @@ import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
import { CommentEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import { canUserAccessDocument } from "@server/utils/permissions";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CommentCreatedNotificationsTask extends BaseTask<CommentEvent> {
@@ -4,7 +4,7 @@ import { MentionType, NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import { canUserAccessDocument } from "@server/utils/permissions";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEvent> {
@@ -1,6 +1,5 @@
import { Op } from "sequelize";
import Logger from "@server/logging/Logger";
import { GroupUser, UserMembership } from "@server/models";
import { GroupUser } from "@server/models";
import { DocumentGroupEvent } from "@server/types";
import BaseTask, { TaskPriority } from "./BaseTask";
import DocumentAddUserNotificationsTask from "./DocumentAddUserNotificationsTask";
@@ -20,26 +19,9 @@ export default class DocumentAddGroupNotificationsTask extends BaseTask<Document
async (groupUsers) => {
await Promise.all(
groupUsers.map(async (groupUser) => {
const userMembership = await UserMembership.findOne({
where: {
userId: groupUser.userId,
documentId: event.documentId,
},
});
if (userMembership) {
Logger.debug(
"task",
`Suppressing notification for user ${groupUser.userId} as they are already a member of the document`,
{
documentId: event.documentId,
userId: groupUser.userId,
}
);
return;
}
await DocumentAddUserNotificationsTask.schedule({
...event,
modelId: event.data.membershipId,
userId: groupUser.userId,
});
})
@@ -1,27 +1,65 @@
import { NotificationEventType } from "@shared/types";
import { DocumentPermission, NotificationEventType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { Notification, User } from "@server/models";
import { DocumentUserEvent } from "@server/types";
import { isElevatedPermission } from "@server/utils/permissions";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class DocumentAddUserNotificationsTask extends BaseTask<DocumentUserEvent> {
public async perform(event: DocumentUserEvent) {
const recipient = await User.findByPk(event.userId);
if (!recipient) {
const permission = event.changes?.attributes.permission as
| DocumentPermission
| undefined;
if (!permission) {
Logger.info(
"task",
`permission not available in the DocumentAddUserNotificationsTask event`,
{
name: event.name,
modelId: event.modelId,
}
);
return;
}
const recipient = await User.findByPk(event.userId);
if (
!recipient.isSuspended &&
recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
!recipient ||
recipient.isSuspended ||
!recipient.subscribedToEventType(NotificationEventType.AddUserToDocument)
) {
await Notification.create({
event: NotificationEventType.AddUserToDocument,
userId: event.userId,
actorId: event.actorId,
teamId: event.teamId,
documentId: event.documentId,
});
return;
}
const isElevated = await isElevatedPermission({
userId: recipient.id,
documentId: event.documentId,
permission,
skipMembershipId: event.modelId,
});
if (!isElevated) {
Logger.debug(
"task",
`Suppressing notification for user ${event.userId} as the new permission does not elevate user's permission to the document`,
{
documentId: event.documentId,
userId: event.userId,
permission,
}
);
return;
}
await Notification.create({
event: NotificationEventType.AddUserToDocument,
userId: event.userId,
actorId: event.actorId,
teamId: event.teamId,
documentId: event.documentId,
membershipId: event.modelId,
});
}
public get options() {
@@ -4,7 +4,7 @@ import { Document, Notification, User } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import { canUserAccessDocument } from "@server/utils/permissions";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class DocumentPublishedNotificationsTask extends BaseTask<DocumentEvent> {
+16 -30
View File
@@ -1,8 +1,9 @@
import { Transaction } from "sequelize";
import { SubscriptionType } from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Subscription, User } from "@server/models";
import Logger from "@server/logging/Logger";
import { Document, Subscription, User } from "@server/models";
import { can } from "@server/policies";
import { sequelize } from "@server/storage/database";
import { DocumentUserEvent } from "@server/types";
import BaseTask from "./BaseTask";
@@ -10,44 +11,29 @@ import BaseTask from "./BaseTask";
export default class DocumentSubscriptionTask extends BaseTask<DocumentUserEvent> {
public async perform(event: DocumentUserEvent) {
const user = await User.findByPk(event.userId);
if (!user) {
if (!user || event.name !== "documents.remove_user") {
return;
}
switch (event.name) {
case "documents.add_user":
return this.addUser(event, user);
case "documents.remove_user":
return this.removeUser(event, user);
default:
}
}
private async addUser(event: DocumentUserEvent, user: User) {
await sequelize.transaction(async (transaction) => {
await subscriptionCreator({
ctx: createContext({
user,
authType: event.authType,
ip: event.ip,
transaction,
}),
documentId: event.documentId,
event: SubscriptionType.Document,
resubscribe: false,
});
const document = await Document.findByPk(event.documentId, {
userId: user.id,
});
}
private async removeUser(event: DocumentUserEvent, user: User) {
if (can(user, "read", document)) {
Logger.debug(
"task",
`Skip unsubscribing user ${user.id} as they have permission to the document ${event.documentId} through other means`
);
return;
}
await sequelize.transaction(async (transaction) => {
const subscription = await Subscription.findOne({
where: {
userId: user.id,
documentId: event.documentId,
event: "documents.update",
event: SubscriptionType.Document,
},
transaction,
lock: Transaction.LOCK.UPDATE,
@@ -0,0 +1,18 @@
import { Node } from "prosemirror-model";
import { schema, serializer } from "@server/editor";
import { Document } from "@server/models";
import { DocumentEvent } from "@server/types";
import BaseTask from "./BaseTask";
export default class DocumentUpdateTextTask extends BaseTask<DocumentEvent> {
public async perform(event: DocumentEvent) {
const document = await Document.findByPk(event.documentId);
if (!document?.content) {
return;
}
const node = Node.fromJSON(schema, document.content);
document.text = serializer.serialize(node);
await document.save({ silent: true });
}
}
@@ -169,14 +169,14 @@ describe("revisions.create", () => {
// Should emit 3 `subscriptions.create` events.
expect(events.length).toEqual(3);
expect(events[0].name).toEqual("subscriptions.create");
expect(events[1].name).toEqual("subscriptions.create");
expect(events[2].name).toEqual("subscriptions.create");
expect(
events.every((event) => event.name === "subscriptions.create")
).toEqual(true);
// Each event should point to same document.
expect(events[0].documentId).toEqual(document.id);
expect(events[1].documentId).toEqual(document.id);
expect(events[2].documentId).toEqual(document.id);
expect(events.every((event) => event.documentId === document.id)).toEqual(
true
);
// Events should mention correct `userId`.
const userIds = events.map((event) => event.userId);
@@ -272,16 +272,15 @@ describe("revisions.create", () => {
// Should emit 2 `subscriptions.create` events.
expect(events.length).toEqual(2);
expect(events[0].name).toEqual("subscriptions.create");
expect(events[1].name).toEqual("subscriptions.create");
// Each event should point to same document.
expect(events[0].documentId).toEqual(document.id);
expect(events[1].documentId).toEqual(document.id);
// Events should mention correct `userId`.
expect(events[0].userId).toEqual(collaborator0.id);
expect(events[1].userId).toEqual(collaborator1.id);
expect(events.every((event) => event.documentId === document.id)).toEqual(
true
);
expect(events.some((event) => event.userId === collaborator0.id)).toEqual(
true
);
expect(events.some((event) => event.userId === collaborator1.id)).toEqual(
true
);
// One notification as one collaborator performed edit and the other is
// unsubscribed
@@ -9,7 +9,7 @@ import { Document, Revision, Notification, User, View } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { RevisionEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/policies";
import { canUserAccessDocument } from "@server/utils/permissions";
import BaseTask, { TaskPriority } from "./BaseTask";
export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionEvent> {
+16 -2
View File
@@ -702,7 +702,7 @@ router.post(
pagination(),
transaction(),
async (ctx: APIContext<T.CollectionsListReq>) => {
const { includeListOnly, statusFilter } = ctx.input.body;
const { includeListOnly, query, statusFilter } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const collectionIds = await user.collectionIds({ transaction });
@@ -728,6 +728,12 @@ router.post(
where[Op.and].push({ id: collectionIds });
}
if (query) {
where[Op.and].push(
Sequelize.literal(`unaccent(LOWER(name)) like unaccent(LOWER(:query))`)
);
}
const statusQuery = [];
if (statusFilter?.includes(CollectionStatusFilter.Archived)) {
statusQuery.push({
@@ -743,6 +749,8 @@ router.post(
});
}
const replacements = { query: `%${query}%` };
const [collections, total] = await Promise.all([
Collection.scope(
statusFilter?.includes(CollectionStatusFilter.Archived)
@@ -757,6 +765,7 @@ router.post(
}
).findAll({
where,
replacements,
order: [
Sequelize.literal('"collection"."index" collate "C"'),
["updatedAt", "DESC"],
@@ -765,7 +774,12 @@ router.post(
limit: ctx.state.pagination.limit,
transaction,
}),
Collection.count({ where, transaction }),
Collection.count({
where,
// @ts-expect-error Types are incorrect for count
replacements,
transaction,
}),
]);
const nullIndex = collections.findIndex(
+3
View File
@@ -178,6 +178,9 @@ export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
export const CollectionsListSchema = BaseSchema.extend({
body: z.object({
includeListOnly: z.boolean().default(false),
query: z.string().optional(),
/** Collection statuses to include in results */
statusFilter: z.nativeEnum(CollectionStatusFilter).array().optional(),
}),
@@ -8,6 +8,7 @@ import {
} from "@shared/types";
import { TextHelper } from "@shared/utils/TextHelper";
import { createContext } from "@server/context";
import { parser } from "@server/editor";
import {
Document,
View,
@@ -3257,21 +3258,26 @@ describe("#documents.restore", () => {
teamId: user.teamId,
});
const revision = await Revision.createFromDocument(document);
const previousText = revision.text;
const previous = revision.content;
const revisionId = revision.id;
// update the document contents
document.text = "UPDATED";
document.content = parser.parse("updated")?.toJSON();
await document.save();
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),
id: document.id,
revisionId,
},
headers: {
"x-api-version": 3,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.text).toEqual(previousText);
expect(body.data.data).toEqual(previous);
});
it("should not allow restoring a revision in another document", async () => {
+4 -1
View File
@@ -11,7 +11,10 @@ export default function apiResponse() {
typeof ctx.body === "object" &&
!(ctx.body instanceof Readable) &&
!(ctx.body instanceof stream.Readable) &&
!(ctx.body instanceof Buffer)
!(ctx.body instanceof Buffer) &&
// JSZip returns a wrapped stream instance that is not a true readable stream
// and not exported from the module either, so we must identify it like so.
!(ctx.body && "_readableState" in ctx.body)
) {
ctx.body = {
...ctx.body,
@@ -1,5 +1,6 @@
import { UserMembership, Revision } from "@server/models";
import {
buildAdmin,
buildCollection,
buildDocument,
buildUser,
@@ -42,6 +43,99 @@ describe("#revisions.info", () => {
});
});
describe("#revisions.update", () => {
it("should update a document revision", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const revision = await Revision.createFromDocument(document);
const res = await server.post("/api/revisions.update", {
body: {
token: user.getJwtToken(),
id: revision.id,
name: "new name",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("new name");
});
it("should allow setting name to null", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const revision = await Revision.createFromDocument(document);
const res = await server.post("/api/revisions.update", {
body: {
token: user.getJwtToken(),
id: revision.id,
name: null,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toBeNull();
});
it("should not allow setting name to empty string", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const revision = await Revision.createFromDocument(document);
const res = await server.post("/api/revisions.update", {
body: {
token: user.getJwtToken(),
id: revision.id,
name: "",
},
});
expect(res.status).toEqual(400);
});
it("should allow an admin to update a document revision", async () => {
const admin = await buildAdmin();
const document = await buildDocument({
teamId: admin.teamId,
});
const revision = await Revision.createFromDocument(document);
const res = await server.post("/api/revisions.update", {
body: {
token: admin.getJwtToken(),
id: revision.id,
name: "new name",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("new name");
});
it("should require authorization", async () => {
const document = await buildDocument();
const revision = await Revision.createFromDocument(document);
const user = await buildUser();
const res = await server.post("/api/revisions.update", {
body: {
token: user.getJwtToken(),
id: revision.id,
name: "new name",
},
});
expect(res.status).toEqual(403);
});
});
describe("#revisions.diff", () => {
it("should return the document HTML if no previous revision", async () => {
const user = await buildUser();
+34 -1
View File
@@ -4,11 +4,12 @@ import { RevisionHelper } from "@shared/utils/RevisionHelper";
import slugify from "@shared/utils/slugify";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { Document, Revision } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { authorize } from "@server/policies";
import { presentRevision } from "@server/presenters";
import { presentPolicies, presentRevision } from "@server/presenters";
import { APIContext } from "@server/types";
import pagination from "../middlewares/pagination";
import * as T from "./schema";
@@ -57,6 +58,36 @@ router.post(
includeStyles: false,
})
),
policies: presentPolicies(user, [after]),
};
}
);
router.post(
"revisions.update",
auth(),
validate(T.RevisionsUpdateSchema),
transaction(),
async (ctx: APIContext<T.RevisionsUpdateReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const revision = await Revision.findByPk(id, {
rejectOnEmpty: true,
});
const document = await Document.findByPk(revision.documentId, {
userId: user.id,
});
authorize(user, "update", document);
authorize(user, "update", revision);
revision.name = name;
await revision.save({ transaction });
ctx.body = {
data: await presentRevision(revision),
policies: presentPolicies(user, [revision]),
};
}
);
@@ -110,6 +141,7 @@ router.post(
ctx.body = {
data: content,
policies: presentPolicies(user, [revision]),
};
}
);
@@ -144,6 +176,7 @@ router.post(
ctx.body = {
pagination: ctx.state.pagination,
data,
policies: presentPolicies(user, revisions),
};
}
);
+15
View File
@@ -1,5 +1,6 @@
import isEmpty from "lodash/isEmpty";
import { z } from "zod";
import { RevisionValidation } from "@shared/validations";
import { Revision } from "@server/models";
import { BaseSchema } from "@server/routes/api/schema";
@@ -25,6 +26,20 @@ export const RevisionsDiffSchema = BaseSchema.extend({
export type RevisionsDiffReq = z.infer<typeof RevisionsDiffSchema>;
export const RevisionsUpdateSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
name: z
.string()
.min(RevisionValidation.minNameLength)
.max(RevisionValidation.maxNameLength)
.or(z.null()),
}),
});
export type RevisionsUpdateReq = z.infer<typeof RevisionsUpdateSchema>;
export const RevisionsListSchema = z.object({
body: z.object({
direction: z
+1
View File
@@ -29,6 +29,7 @@ export type SharesInfoReq = z.infer<typeof SharesInfoSchema>;
export const SharesListSchema = BaseSchema.extend({
body: z.object({
query: z.string().optional(),
sort: z
.string()
.refine((val) => Object.keys(Share.getAttributes()).includes(val), {
+52
View File
@@ -57,6 +57,58 @@ describe("#shares.list", () => {
expect(body.data[0].documentTitle).toBe(document.title);
});
it("should allow filtering by document title", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
title: "hardcoded",
});
await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: {
token: user.getJwtToken(),
query: "test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should allow filtering by document title and return matching shares", async () => {
const user = await buildUser();
await buildDocument({
userId: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
title: "test",
});
const share = await buildShare({
documentId: document.id,
teamId: user.teamId,
userId: user.id,
});
const res = await server.post("/api/shares.list", {
body: {
token: user.getJwtToken(),
query: "test",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(share.id);
expect(body.data[0].documentTitle).toBe("test");
});
it("should not return revoked shares", async () => {
const user = await buildUser();
const document = await buildDocument({
+14 -6
View File
@@ -98,9 +98,10 @@ router.post(
pagination(),
validate(T.SharesListSchema),
async (ctx: APIContext<T.SharesListReq>) => {
const { sort, direction } = ctx.input.body;
const { sort, direction, query } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "listShares", user.team);
const collectionIds = await user.collectionIds();
const where: WhereOptions<Share> = {
teamId: user.teamId,
@@ -111,12 +112,21 @@ router.post(
},
};
const documentWhere: WhereOptions<Document> = {
teamId: user.teamId,
collectionId: collectionIds,
};
if (query) {
documentWhere.title = {
[Op.iLike]: `%${query}%`,
};
}
if (user.isAdmin) {
delete where.userId;
}
const collectionIds = await user.collectionIds();
const options: FindOptions = {
where,
include: [
@@ -125,9 +135,7 @@ router.post(
required: true,
paranoid: true,
as: "document",
where: {
collectionId: collectionIds,
},
where: documentWhere,
include: [
{
model: Collection.scope({
+3 -1
View File
@@ -23,7 +23,7 @@ router.post(
const { offset, limit } = ctx.state.pagination;
const actor = ctx.state.auth.user;
const [documents, users] = await Promise.all([
const [documents, users, collections] = await Promise.all([
SearchHelper.searchTitlesForUser(actor, {
query,
offset,
@@ -53,6 +53,7 @@ router.post(
offset,
limit,
}),
SearchHelper.searchCollectionsForUser(actor, { query, offset, limit }),
]);
ctx.body = {
@@ -67,6 +68,7 @@ router.post(
includeDetails: !!can(actor, "readDetails", user),
})
),
collections,
},
};
}
+9 -13
View File
@@ -1,6 +1,7 @@
import { Blob } from "buffer";
import { Readable } from "stream";
import { PresignedPost } from "@aws-sdk/s3-presigned-post";
import FileHelper from "@shared/editor/lib/FileHelper";
import { isBase64Url, isInternalUrl } from "@shared/utils/urls";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -239,14 +240,14 @@ export default abstract class BaseStorage {
* @returns The content disposition
*/
public getContentDisposition(contentType?: string) {
if (contentType && this.safeInlineContentTypes.includes(contentType)) {
return "inline";
if (!contentType) {
return "attachment";
}
if (
contentType &&
this.safeInlineContentPrefixes.some((prefix) =>
contentType.startsWith(prefix)
)
FileHelper.isAudio(contentType) ||
FileHelper.isVideo(contentType) ||
this.safeInlineContentTypes.includes(contentType)
) {
return "inline";
}
@@ -255,8 +256,8 @@ export default abstract class BaseStorage {
}
/**
* A list of content types considered safe to display inline in the browser. Note that
* SVGs are purposefully not included here as they can contain JavaScript.
* A list of content types considered safe to display inline in the browser.
* Note that SVGs are purposefully not included here as they can contain JS.
*/
protected safeInlineContentTypes = [
"application/pdf",
@@ -265,9 +266,4 @@ export default abstract class BaseStorage {
"image/gif",
"image/webp",
];
/**
* A list of content type prefixes considered safe to display inline in the browser.
*/
protected safeInlineContentPrefixes = ["video/", "audio/"];
}
+4 -1
View File
@@ -294,11 +294,14 @@ export async function buildCollection(
overrides.archivedById = overrides.userId;
}
if (overrides.permission === undefined) {
overrides.permission = CollectionPermission.ReadWrite;
}
return Collection.create({
name: faker.lorem.words(2),
description: faker.lorem.words(4),
createdById: overrides.userId,
permission: CollectionPermission.ReadWrite,
...overrides,
});
}
+1
View File
@@ -464,6 +464,7 @@ export type NotificationEvent = BaseEvent<Notification> & {
commentId?: string;
documentId?: string;
collectionId?: string;
membershipId?: string;
};
export type Event =
+305
View File
@@ -0,0 +1,305 @@
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { GroupMembership, UserMembership } from "@server/models";
import {
buildCollection,
buildDocument,
buildGroup,
buildGroupUser,
buildUser,
} from "@server/test/factories";
import { getDocumentPermission, isElevatedPermission } from "./permissions";
describe("permissions", () => {
describe("isElevatedPermission", () => {
it("should return false when user has higher permission through collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
await UserMembership.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
const isElevated = await isElevatedPermission({
userId: user.id,
documentId: document.id,
permission: DocumentPermission.Read,
});
expect(isElevated).toBe(false);
});
it("should return false when user has higher permission through document", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const group = await buildGroup();
await Promise.all([
await buildGroupUser({
groupId: group.id,
userId: user.id,
teamId: user.teamId,
}),
await UserMembership.create({
createdById: user.id,
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
}),
await GroupMembership.create({
createdById: user.id,
documentId: document.id,
groupId: group.id,
permission: DocumentPermission.ReadWrite,
}),
]);
const isElevated = await isElevatedPermission({
userId: user.id,
documentId: document.id,
permission: DocumentPermission.Read,
});
expect(isElevated).toBe(false);
});
it("should return false when user has the same permission", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const group = await buildGroup();
await Promise.all([
await buildGroupUser({
groupId: group.id,
userId: user.id,
teamId: user.teamId,
}),
await UserMembership.create({
createdById: user.id,
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
}),
await GroupMembership.create({
createdById: user.id,
documentId: document.id,
groupId: group.id,
permission: DocumentPermission.ReadWrite,
}),
]);
const isElevated = await isElevatedPermission({
userId: user.id,
documentId: document.id,
permission: DocumentPermission.ReadWrite,
});
expect(isElevated).toBe(false);
});
it("should return true when user has lower permission", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const group = await buildGroup();
await Promise.all([
await buildGroupUser({
groupId: group.id,
userId: user.id,
teamId: user.teamId,
}),
await UserMembership.create({
createdById: user.id,
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
}),
await GroupMembership.create({
createdById: user.id,
documentId: document.id,
groupId: group.id,
permission: DocumentPermission.ReadWrite,
}),
]);
const isElevated = await isElevatedPermission({
userId: user.id,
documentId: document.id,
permission: DocumentPermission.Admin,
});
expect(isElevated).toBe(true);
});
it("should return true when user does not have access", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const isElevated = await isElevatedPermission({
userId: user.id,
documentId: document.id,
permission: DocumentPermission.Admin,
});
expect(isElevated).toBe(true);
});
});
describe("getDocumentPermission", () => {
it("should return the highest provided permission through collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
await UserMembership.create({
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: CollectionPermission.ReadWrite,
});
const permission = await getDocumentPermission({
userId: user.id,
documentId: document.id,
});
expect(permission).toEqual(DocumentPermission.ReadWrite);
});
it("should return the highest provided permission through document", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const group = await buildGroup();
await Promise.all([
await buildGroupUser({
groupId: group.id,
userId: user.id,
teamId: user.teamId,
}),
await UserMembership.create({
createdById: user.id,
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
}),
await GroupMembership.create({
createdById: user.id,
documentId: document.id,
groupId: group.id,
permission: DocumentPermission.ReadWrite,
}),
]);
const permission = await getDocumentPermission({
userId: user.id,
documentId: document.id,
});
expect(permission).toEqual(DocumentPermission.ReadWrite);
});
it("should return the highest provided permission with skipped membership", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const group = await buildGroup();
const [, , groupMembership] = await Promise.all([
await buildGroupUser({
groupId: group.id,
userId: user.id,
teamId: user.teamId,
}),
await UserMembership.create({
createdById: user.id,
documentId: document.id,
userId: user.id,
permission: DocumentPermission.Read,
}),
await GroupMembership.create({
createdById: user.id,
documentId: document.id,
groupId: group.id,
permission: DocumentPermission.ReadWrite,
}),
]);
const permission = await getDocumentPermission({
userId: user.id,
documentId: document.id,
skipMembershipId: groupMembership.id,
});
expect(permission).toEqual(DocumentPermission.Read);
});
it("should return undefined when user does not have access", async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
permission: null,
});
const document = await buildDocument({
collectionId: collection.id,
teamId: user.teamId,
});
const permission = await getDocumentPermission({
userId: user.id,
documentId: document.id,
});
expect(permission).toBeUndefined();
});
});
});
+178
View File
@@ -0,0 +1,178 @@
import compact from "lodash/compact";
import orderBy from "lodash/orderBy";
import { Op, WhereOptions } from "sequelize";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import {
Document,
Group,
GroupMembership,
User,
UserMembership,
} from "@server/models";
import { authorize } from "@server/policies";
// Higher value takes precedence
export const CollectionPermissionPriority = {
[CollectionPermission.Admin]: 2,
[CollectionPermission.ReadWrite]: 1,
[CollectionPermission.Read]: 0,
} satisfies Record<CollectionPermission, number>;
// Higher value takes precedence
export const DocumentPermissionPriority = {
[DocumentPermission.Admin]: 2,
[DocumentPermission.ReadWrite]: 1,
[DocumentPermission.Read]: 0,
} satisfies Record<DocumentPermission, number>;
/**
* Check if the given user can access a document
*
* @param user - The user to check
* @param documentId - The document to check
* @returns Boolean whether the user can access the document
*/
export const canUserAccessDocument = async (user: User, documentId: string) => {
try {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
return true;
} catch (err) {
return false;
}
};
/**
* Determines whether the user's access to a document is being elevated with the new permission.
*
* @param {Object} params Input parameters.
* @param {string} params.userId The user to check.
* @param {string} params.documentId The document to check.
* @param {DocumentPermission} params.permission The new permission given to the user.
* @param {string} params.skipMembershipId The membership to skip when comparing the existing permissions.
* @returns {boolean} Whether the user has a higher access level
*/
export const isElevatedPermission = async ({
userId,
documentId,
permission,
skipMembershipId,
}: {
userId: string;
documentId: string;
permission: DocumentPermission;
skipMembershipId?: string;
}) => {
const existingPermission = await getDocumentPermission({
userId,
documentId,
skipMembershipId,
});
if (!existingPermission) {
return true;
}
return (
DocumentPermissionPriority[existingPermission] <
DocumentPermissionPriority[permission]
);
};
/**
* Returns the user's permission to a document.
*
* @param {Object} params Input parameters.
* @param {string} params.userId The user to check.
* @param {string} params.documentId The document to check.
* @param {string} params.skipMembershipId The membership to skip when comparing the existing permissions.
* @returns {DocumentPermission | undefined} Highest permission, if it exists.
*/
export const getDocumentPermission = async ({
userId,
documentId,
skipMembershipId,
}: {
userId: string;
documentId: string;
skipMembershipId?: string;
}): Promise<DocumentPermission | undefined> => {
const document = await Document.scope({
method: ["withCollectionPermissions", userId],
}).findOne({ where: { id: documentId } });
const permissions: DocumentPermission[] = [];
const collection = document?.collection;
if (collection) {
const collectionPermissions = orderBy(
compact([
collection.permission,
...compact(
collection.memberships?.map(
(m) => m.permission as CollectionPermission
)
),
...compact(
collection.groupMemberships?.map(
(m) => m.permission as CollectionPermission
)
),
]),
(permission) => CollectionPermissionPriority[permission],
"desc"
);
if (collectionPermissions[0]) {
permissions.push(
collectionPermissions[0] === CollectionPermission.Read
? DocumentPermission.Read
: DocumentPermission.ReadWrite
);
}
}
const userMembershipWhere: WhereOptions<UserMembership> = {
userId,
documentId,
};
const groupMembershipWhere: WhereOptions<GroupMembership> = {
documentId,
};
if (skipMembershipId) {
userMembershipWhere.id = { [Op.ne]: skipMembershipId };
groupMembershipWhere.id = { [Op.ne]: skipMembershipId };
}
const [userMemberships, groupMemberships] = await Promise.all([
UserMembership.findAll({
where: userMembershipWhere,
}),
GroupMembership.findAll({
where: groupMembershipWhere,
include: [
{
model: Group.filterByMember(userId),
as: "group",
required: true,
},
],
}),
]);
permissions.push(
...userMemberships.map((m) => m.permission as DocumentPermission),
...groupMemberships.map((m) => m.permission as DocumentPermission)
);
const orderedPermissions = orderBy(
permissions,
(permission) => DocumentPermissionPriority[permission],
"desc"
);
return orderedPermissions[0];
};
-21
View File
@@ -1,21 +0,0 @@
import { Document, User } from "@server/models";
import { authorize } from "@server/policies";
/**
* Check if the given user can access a document
*
* @param user - The user to check
* @param documentId - The document to check
* @returns Boolean whether the user can access the document
*/
export const canUserAccessDocument = async (user: User, documentId: string) => {
try {
const document = await Document.findByPk(documentId, {
userId: user.id,
});
authorize(user, "read", document);
return true;
} catch (err) {
return false;
}
};
+1 -1
View File
@@ -32,6 +32,6 @@ export const UserPreferenceDefaults: UserPreferences = {
[UserPreference.RememberLastPath]: true,
[UserPreference.UseCursorPointer]: true,
[UserPreference.CodeBlockLineNumers]: true,
[UserPreference.SortCommentsByOrderInDocument]: false,
[UserPreference.SortCommentsByOrderInDocument]: true,
[UserPreference.EnableSmartText]: true,
};
+2 -2
View File
@@ -58,11 +58,11 @@ const insertFiles = async function (
const filesToUpload = await Promise.all(
files.map(async (file) => {
const isImage =
FileHelper.isImage(file) &&
FileHelper.isImage(file.type) &&
!options.isAttachment &&
!!schema.nodes.image;
const isVideo =
FileHelper.isVideo(file) &&
FileHelper.isVideo(file.type) &&
!options.isAttachment &&
!!schema.nodes.video;
const getDimensions = isImage
+46 -1
View File
@@ -1,3 +1,4 @@
import { GapCursor } from "prosemirror-gapcursor";
import { Node, NodeType } from "prosemirror-model";
import { Command, EditorState, TextSelection } from "prosemirror-state";
import {
@@ -12,6 +13,7 @@ import {
deleteColumn,
} from "prosemirror-tables";
import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper";
import { CSVHelper } from "../../utils/csv";
import { chainTransactions } from "../lib/chainTransactions";
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
import { TableLayout } from "../types";
@@ -136,7 +138,7 @@ export function exportTable({
}
// Avoid cell content being interpreted as formulas by adding a leading single quote
value = value.trimStart().replace(/^([+\-=@])/, "'$1");
value = CSVHelper.sanitizeValue(value);
return `"${value}"`;
})
@@ -499,3 +501,46 @@ export function selectTable(): Command {
return false;
};
}
export function moveOutOfTable(direction: 1 | -1): Command {
return (state, dispatch): boolean => {
if (dispatch) {
if (state.selection instanceof GapCursor) {
return false;
}
if (!isInTable(state)) {
return false;
}
// check if current cursor position is at the top or bottom of the table
const rect = selectedRect(state);
const topOfTable =
rect.top === 0 && rect.bottom === 1 && direction === -1;
const bottomOfTable =
rect.top === rect.map.height - 1 &&
rect.bottom === rect.map.height &&
direction === 1;
if (!topOfTable && !bottomOfTable) {
return false;
}
const map = rect.map.map;
const $start = state.doc.resolve(rect.tableStart + map[0] - 1);
const $end = state.doc.resolve(rect.tableStart + map[map.length - 1] + 2);
// @ts-expect-error findGapCursorFrom is a ProseMirror internal method.
const $found = GapCursor.findGapCursorFrom(
direction > 0 ? $end : $start,
direction,
true
);
if ($found) {
dispatch(state.tr.setSelection(new GapCursor($found)));
return true;
}
}
return false;
};
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { DocumentIcon, EmailIcon } from "outline-icons";
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import * as React from "react";
import { Link } from "react-router-dom";
@@ -67,3 +67,36 @@ export const MentionDocument = observer(function MentionDocument_(
</Link>
);
});
export const MentionCollection = observer(function MentionCollection_(
props: ComponentProps
) {
const { isSelected, node } = props;
const { collections } = useStores();
const collection = collections.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
void collections.fetch(modelId);
}
}, [modelId, collections]);
return (
<Link
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
to={collection?.path ?? `/collection/${node.attrs.modelId}`}
>
{collection?.icon ? (
<Icon value={collection?.icon} color={collection?.color} size={18} />
) : (
<CollectionIcon size={18} />
)}
{collection?.title || node.attrs.label}
</Link>
);
});
+1 -1
View File
@@ -589,7 +589,7 @@ const embeds: EmbedDescriptor[] = [
title: "Tldraw",
keywords: "draw schematics diagrams",
regexMatch: [
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvo]+/(.*)"),
new RegExp("^https?://(beta|www|old)\\.tldraw\\.com/[rsvopf]+/(.*)"),
],
transformMatch: (matches: RegExpMatchArray) => matches[0],
icon: <Img src="/images/tldraw.png" alt="Tldraw" />,
+34
View File
@@ -0,0 +1,34 @@
import FileHelper from "./FileHelper";
describe("FileHelper", () => {
it("isImage", () => {
expect(FileHelper.isImage("image/png")).toBe(true);
expect(FileHelper.isImage("image/jpeg")).toBe(true);
expect(FileHelper.isImage("image/webp")).toBe(true);
expect(FileHelper.isImage("image/gif")).toBe(true);
expect(FileHelper.isImage("image/bmp")).toBe(true);
expect(FileHelper.isImage("image/avif")).toBe(true);
expect(FileHelper.isImage("image/heif-sequence")).toBe(true);
expect(FileHelper.isImage("image/svg+xml")).toBe(true);
expect(FileHelper.isImage("text/plain")).toBe(false);
expect(FileHelper.isImage("application/json")).toBe(false);
});
it("isVideo", () => {
expect(FileHelper.isVideo("video/mp4")).toBe(true);
expect(FileHelper.isVideo("video/webm")).toBe(true);
expect(FileHelper.isVideo("video/x-msvideo")).toBe(true);
expect(FileHelper.isVideo("video/vnd.dlna.mpeg-tts")).toBe(true);
expect(FileHelper.isVideo("text/plain")).toBe(false);
expect(FileHelper.isVideo("application/json")).toBe(false);
});
it("isAudio", () => {
expect(FileHelper.isAudio("audio/mpeg")).toBe(true);
expect(FileHelper.isAudio("audio/wav")).toBe(true);
expect(FileHelper.isAudio("audio/vnd.dolby.heaac.1")).toBe(true);
expect(FileHelper.isAudio("audio/vnd.lucent.voice")).toBe(true);
expect(FileHelper.isAudio("text/plain")).toBe(false);
expect(FileHelper.isAudio("application/json")).toBe(false);
});
});
+16 -6
View File
@@ -4,21 +4,31 @@ export default class FileHelper {
/**
* Checks if a file is an image.
*
* @param file The file to check
* @param contentType The content type of the file
* @returns True if the file is an image
*/
static isImage(file: File) {
return file.type.startsWith("image/");
static isImage(contentType: string) {
return /^image\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
}
/**
* Checks if a file is a video.
*
* @param file The file to check
* @param contentType The content type of the file
* @returns True if the file is an video
*/
static isVideo(file: File) {
return file.type.startsWith("video/");
static isVideo(contentType: string) {
return /^video\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
}
/**
* Checks if a file is an audio file.
*
* @param contentType The content type of the file
* @returns True if the file is an audio file
*/
static isAudio(contentType: string) {
return /^audio\/[!#$%&'*+.^\w`|~-]+$/i.test(contentType);
}
/**
+2 -2
View File
@@ -21,8 +21,8 @@ export default class Code extends Mark {
get schema(): MarkSpec {
return {
excludes: "mention link placeholder highlight em strong",
parseDOM: [{ tag: "code.inline", preserveWhitespace: true }],
excludes: "mention placeholder highlight em strong",
parseDOM: [{ tag: "code", preserveWhitespace: true }],
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
};
}
+1 -1
View File
@@ -22,7 +22,7 @@ export default class CheckboxItem extends Node {
default: false,
},
},
content: "paragraph block*",
content: "block+",
defining: true,
draggable: true,
parseDOM: [
+12 -2
View File
@@ -173,8 +173,6 @@ export default class CodeFence extends Node {
defining: true,
draggable: false,
parseDOM: [
{ tag: "code" },
{ tag: "pre", preserveWhitespace: "full" },
{
tag: ".code-block",
preserveWhitespace: "full",
@@ -184,6 +182,18 @@ export default class CodeFence extends Node {
language: dom.dataset.language,
}),
},
{
tag: "code",
preserveWhitespace: "full",
getAttrs: (dom) => {
// Only parse code blocks that contain newlines for code fences,
// otherwise the code mark rule will be applied.
if (!dom.textContent?.includes("\n")) {
return false;
}
return { language: dom.dataset.language };
},
},
],
toDOM: (node) => [
"div",
+1 -1
View File
@@ -26,7 +26,7 @@ export default class ListItem extends Node {
get schema(): NodeSpec {
return {
content: "paragraph block*",
content: "block+",
defining: true,
draggable: true,
parseDOM: [{ tag: "li" }],
+25 -4
View File
@@ -16,7 +16,11 @@ import { Primitive } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import { MentionType } from "../../types";
import { MentionDocument, MentionUser } from "../components/Mentions";
import {
MentionCollection,
MentionDocument,
MentionUser,
} from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import mentionRule from "../rules/mention";
import { ComponentProps } from "../types";
@@ -76,7 +80,9 @@ export default class Mention extends Node {
href:
node.attrs.type === MentionType.User
? undefined
: `${env.URL}/doc/${node.attrs.modelId}`,
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
: `${env.URL}/collection/${node.attrs.modelId}`,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorid": node.attrs.actorId,
@@ -97,6 +103,8 @@ export default class Mention extends Node {
return <MentionUser {...props} />;
case MentionType.Document:
return <MentionDocument {...props} />;
case MentionType.Collection:
return <MentionCollection {...props} />;
default:
return null;
}
@@ -145,10 +153,23 @@ export default class Mention extends Node {
if (
selection instanceof NodeSelection &&
selection.node.type.name === this.name &&
selection.node.attrs.type === MentionType.Document
(selection.node.attrs.type === MentionType.Document ||
selection.node.attrs.type === MentionType.Collection)
) {
const { modelId } = selection.node.attrs;
this.editor.props.onClickLink?.(`/doc/${modelId}`);
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: selection.node.attrs.type === MentionType.Collection
? "collection"
: undefined;
if (!linkType) {
return false;
}
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
return true;
}
return false;
+3
View File
@@ -22,6 +22,7 @@ import {
setTableAttr,
deleteColSelection,
deleteRowSelection,
moveOutOfTable,
} from "../commands/table";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { FixTablesPlugin } from "../plugins/FixTables";
@@ -95,6 +96,8 @@ export default class Table extends Node {
deleteColSelection(),
deleteRowSelection()
),
ArrowDown: moveOutOfTable(1),
ArrowUp: moveOutOfTable(-1),
};
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Vložený kód",
"Inline LaTeX": "Vložený LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Přihlásit se",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Vytvořili jste před {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} vytvořil před {{ timeAgo }}",
"Uploading": "Nahrávání"
}
}

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