Compare commits

...

42 Commits

Author SHA1 Message Date
Tom Moor f8cd5f3e4b fix: Address PR feedback 2025-01-28 19:38:46 -05:00
Tom Moor 39852470cc Update API key list UI 2025-01-27 22:13:47 -05:00
Tom Moor 9a03e1c947 Update API key UI 2025-01-27 22:08:53 -05:00
Tom Moor cfaa08403a Store scopes with full url 2025-01-27 21:35:42 -05:00
Tom Moor 99bc586f34 Switch to storing array 2025-01-27 21:22:54 -05:00
Tom Moor 75838bb311 Merge branch 'main' into tom/api-scopes 2025-01-27 20:30:30 -05:00
dependabot[bot] f1c5b145a4 chore(deps-dev): bump @types/node from 20.17.14 to 20.17.16 (#8311)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.14 to 20.17.16.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  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-01-27 17:27:51 -08:00
dependabot[bot] 4c7b36dfca chore(deps): bump react-hook-form from 7.53.1 to 7.54.2 (#8310)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.53.1 to 7.54.2.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.53.1...v7.54.2)

---
updated-dependencies:
- dependency-name: react-hook-form
  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-01-27 17:27:40 -08:00
dependabot[bot] e1d0d4717c chore(deps): bump @tanstack/react-virtual from 3.10.9 to 3.11.3 (#8312)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.10.9 to 3.11.3.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Commits](https://github.com/TanStack/virtual/commits/v3.11.3/packages/react-virtual)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  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-01-27 17:27:03 -08:00
dependabot[bot] e3f836c22b chore(deps): bump the babel group with 6 updates (#8308)
Bumps the babel group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) | `7.25.2` | `7.26.7` |
| [@babel/plugin-proposal-decorators](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-proposal-decorators) | `7.24.7` | `7.25.9` |
| [@babel/plugin-transform-class-properties](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-class-properties) | `7.25.7` | `7.25.9` |
| [@babel/plugin-transform-destructuring](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-destructuring) | `7.25.7` | `7.25.9` |
| [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) | `7.25.8` | `7.26.7` |
| [@babel/preset-typescript](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-typescript) | `7.24.1` | `7.26.0` |


Updates `@babel/core` from 7.25.2 to 7.26.7
- [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.7/packages/babel-core)

Updates `@babel/plugin-proposal-decorators` from 7.24.7 to 7.25.9
- [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.25.9/packages/babel-plugin-proposal-decorators)

Updates `@babel/plugin-transform-class-properties` from 7.25.7 to 7.25.9
- [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.25.9/packages/babel-plugin-transform-class-properties)

Updates `@babel/plugin-transform-destructuring` from 7.25.7 to 7.25.9
- [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.25.9/packages/babel-plugin-transform-destructuring)

Updates `@babel/preset-env` from 7.25.8 to 7.26.7
- [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.7/packages/babel-preset-env)

Updates `@babel/preset-typescript` from 7.24.1 to 7.26.0
- [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.0/packages/babel-preset-typescript)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-proposal-decorators"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-class-properties"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/plugin-transform-destructuring"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: babel
- dependency-name: "@babel/preset-env"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: babel
- dependency-name: "@babel/preset-typescript"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: babel
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 17:26:32 -08:00
dependabot[bot] e9602ada24 chore(deps): bump @dnd-kit/core from 6.1.0 to 6.3.1 (#8304)
Bumps [@dnd-kit/core](https://github.com/clauderic/dnd-kit/tree/HEAD/packages/core) from 6.1.0 to 6.3.1.
- [Release notes](https://github.com/clauderic/dnd-kit/releases)
- [Changelog](https://github.com/clauderic/dnd-kit/blob/master/packages/core/CHANGELOG.md)
- [Commits](https://github.com/clauderic/dnd-kit/commits/@dnd-kit/core@6.3.1/packages/core)

---
updated-dependencies:
- dependency-name: "@dnd-kit/core"
  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-01-27 17:18:50 -08:00
Tom Moor 0ff4bed18f chore: Add groups to dependabot 2025-01-27 20:13:10 -05:00
dependabot[bot] 6b49d91f2f chore(deps): bump mailparser and @types/mailparser (#8306)
Bumps [mailparser](https://github.com/nodemailer/mailparser) and [@types/mailparser](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mailparser). These dependencies needed to be updated together.

Updates `mailparser` from 3.7.1 to 3.7.2
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.7.1...v3.7.2)

Updates `@types/mailparser` from 3.4.4 to 3.4.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mailparser)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/mailparser"
  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-01-27 17:03:36 -08:00
dependabot[bot] 77f0572445 chore(deps): bump @tanstack/react-table from 8.20.5 to 8.20.6 (#8307)
Bumps [@tanstack/react-table](https://github.com/TanStack/table/tree/HEAD/packages/react-table) from 8.20.5 to 8.20.6.
- [Release notes](https://github.com/TanStack/table/releases)
- [Commits](https://github.com/TanStack/table/commits/v8.20.6/packages/react-table)

---
updated-dependencies:
- dependency-name: "@tanstack/react-table"
  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-01-27 17:03:21 -08:00
Tom Moor 5b11a0cc16 feat: Upgrade FA, add new icons 2025-01-25 15:19:58 -05:00
Tom Moor dfe97bee50 fix: Comment sidebar should not overtake scrolling if linked to anchor, closes #8296 2025-01-25 11:44:38 -05:00
Tom Moor 500730b243 chore: React warning 2025-01-25 08:09:43 -05:00
Tom Moor ec6ed809a4 Add triggers to keyboard shortcut help, make search input sticky 2025-01-25 07:49:06 -05:00
Tom Moor 8b3115be9a test 2025-01-25 00:30:00 -05:00
Tom Moor 7782292500 Allow creation 2025-01-25 00:24:13 -05:00
Tom Moor a7da968499 Add scope restriction 2025-01-24 23:24:49 -05:00
Tom Moor a95005776f scope storage 2025-01-24 22:45:32 -05:00
Viorel Cojocaru 08385b8a9e chore: Bump rollup-plugin-webpack-stats to v2.0.1 (#8293) 2025-01-24 15:04:07 -08:00
Tom Moor 9929020b44 chore: fix WSS connection issue in local development 2025-01-24 09:44:54 -05:00
Tom Moor 48a330347f chore: fix CORS issue in local development 2025-01-24 09:21:54 -05:00
Tom Moor 5b6bebc308 fix: Email content should account for untitled documents 2025-01-23 23:46:02 -05:00
Tom Moor c831c71c51 fix: Incorrect horizontal borders on Settings -> Profile 2025-01-23 23:41:55 -05:00
Tom Moor 90350e82fe fix: Events lacking teamId published for sourced memberships (#8295) 2025-01-23 20:19:53 -08:00
Tom Moor b7bbaac2eb fix: Default to user mention for backwards compat 2025-01-23 22:03:27 -05:00
Hemachandar 5a45b95a48 fix: Render TOC only when the shared document has headings (#8264)
* fix: Render TOC only when the shared document has headings

* simplify condition

* fix inconsistent toc button state

* toc visible check

* remove shareHasHeadings prop
2025-01-23 05:12:34 -08:00
Hemachandar 9deb9268b5 fix: Skip events for sourced group memberships (#8286) 2025-01-23 05:06:57 -08:00
Tom Moor 53f4c724bb chore: Remove duplicate trigger definition for suggestion extensions 2025-01-22 22:29:36 -05:00
Tom Moor 184e56264c feat: Add reading time on pinned documents 2025-01-22 21:17:26 -05:00
Tom Moor ffa7043cf0 fix: Outgoing emails trigger spoofing warnings due to exact matching from name 2025-01-22 20:55:10 -05:00
Tom Moor ff3c157554 fix: Crash in share menu when query looks like regex 2025-01-22 20:52:00 -05:00
Tom Moor 13f23d19fc fix: JS error selecting 'Keep as link' with keyboard.
Hacky quick fix, better coming soon
closes #8276
2025-01-22 20:47:38 -05:00
Hemachandar b527048b76 Remove namespace filter for publishing events (#8252)
* groupuser namespace

* remove namespace

* handle reactions

* handle group memberships

* cache changeset before all create and update flows
2025-01-22 17:16:05 -08:00
Apoorv Mishra e1b0cfb6a0 Use explicitly passed title and text in favor of template title and text (#8274)
* fix: use explicitly passed title and text in favor of template title and text while creating doc from the template

* fix: retain template variables when creating a template from another template
2025-01-22 05:41:18 -08:00
ZhuoYang Wu(阿离) 2205b9ee87 feat: add paste menu (#8229)
* feat: add paste menu

* fix: for comment

* Add supported embed detection

* fix: Menu is modified before it closes

* Add multiplayer support

* refactor

* perf: Avoid unneccessary mapping

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-22 05:41:05 -08:00
dependabot[bot] 1122f030a9 chore(deps): bump vite from 5.4.11 to 5.4.12 (#8273)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 05:15:10 -08:00
Translate-O-Tron 4cc0beb90d New Crowdin updates (#8257)
* fix: New Norwegian Bokmal translations from Crowdin [ci skip]

* fix: New Norwegian Bokmal translations from Crowdin [ci skip]
2025-01-20 16:00:33 -08:00
dependabot[bot] 16084322ca chore(deps-dev): bump @types/node from 20.14.2 to 20.17.14 (#8261)
* chore(deps-dev): bump @types/node from 20.14.2 to 20.17.14

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.14.2 to 20.17.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* tsc

* tsc

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-01-20 16:00:08 -08:00
74 changed files with 1864 additions and 1269 deletions
+13
View File
@@ -13,3 +13,16 @@ updates:
update-types: ["version-update:semver-major"]
schedule:
interval: "weekly"
groups:
babel:
patterns:
- "@babel/*"
sentry:
patterns:
- "@sentry/*"
fortawesome:
patterns:
- "@fortawesome/*"
aws:
patterns:
- "@aws-sdk/*"
+1 -1
View File
@@ -23,7 +23,7 @@ type Props = {
as?: string | React.ComponentType<any>;
hide?: () => void;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactNode;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
+30 -3
View File
@@ -1,8 +1,9 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon } from "outline-icons";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
@@ -18,6 +19,7 @@ import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import { useTextStats } from "~/hooks/useTextStats";
import CollectionIcon from "./Icons/CollectionIcon";
import Text from "./Text";
import Tooltip from "./Tooltip";
@@ -70,6 +72,10 @@ function DocumentCard(props: Props) {
[pin]
);
// If the document was updated within the last 7 days, show a timestamp instead of reading time
const isRecentlyUpdated =
new Date(document.updatedAt) > subDays(new Date(), 7);
return (
<Reorderable
ref={setNodeRef}
@@ -142,8 +148,14 @@ function DocumentCard(props: Props) {
: document.titleWithDefault}
</Heading>
<DocumentMeta size="xsmall">
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
{isRecentlyUpdated ? (
<>
<Clock size={18} />
<Time dateTime={document.updatedAt} addSuffix shorten />
</>
) : (
<ReadingTime document={document} />
)}
</DocumentMeta>
</div>
</Content>
@@ -164,6 +176,21 @@ function DocumentCard(props: Props) {
);
}
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
<>
<EyeIcon size={18} />
{t(`{{ minutes }}m read`, {
minutes: stats.total.readingTime,
})}
</>
);
};
const DocumentSquircle = ({
icon,
color,
+1 -4
View File
@@ -6,10 +6,7 @@ import SuggestionsMenu, {
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "trigger"
> &
type Props = Omit<SuggestionsMenuProps, "renderMenuItem" | "items"> &
Required<Pick<SuggestionsMenuProps, "embeds">>;
function BlockMenu(props: Props) {
+1 -2
View File
@@ -17,7 +17,7 @@ type Emoji = {
type Props = Omit<
SuggestionsMenuProps<Emoji>,
"renderMenuItem" | "items" | "embeds" | "trigger"
"renderMenuItem" | "items" | "embeds"
>;
const EmojiMenu = (props: Props) => {
@@ -48,7 +48,6 @@ const EmojiMenu = (props: Props) => {
return (
<SuggestionsMenu
{...props}
trigger=":"
filterable={false}
renderMenuItem={(item, _index, options) => (
<EmojiMenuItem
+1 -2
View File
@@ -35,7 +35,7 @@ interface MentionItem extends MenuItem {
type Props = Omit<
SuggestionsMenuProps<MentionItem>,
"renderMenuItem" | "items" | "embeds" | "trigger"
"renderMenuItem" | "items" | "embeds"
>;
function MentionMenu({ search, isActive, ...rest }: Props) {
@@ -194,7 +194,6 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
{...rest}
isActive={isActive}
filterable={false}
trigger="@"
search={search}
onSelect={handleSelect}
renderMenuItem={(item, _index, options) => (
+68
View File
@@ -0,0 +1,68 @@
import { LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { EmbedDescriptor } from "@shared/editor/embeds";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
import SuggestionsMenuItem from "./SuggestionsMenuItem";
type Props = Omit<
SuggestionsMenuProps,
"renderMenuItem" | "items" | "embeds" | "trigger"
> & {
pastedText: string;
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
const { t } = useTranslation();
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
);
return (
<SuggestionsMenu
{...props}
trigger=""
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
selected={options.selected}
title={item.title}
icon={item.icon}
/>
)}
items={items}
/>
);
};
export default PasteMenu;
+5 -2
View File
@@ -233,7 +233,9 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
const attrs =
typeof item.attrs === "function" ? item.attrs(view.state) : item.attrs;
if (command) {
if (item.name === "noop") {
// Do nothing
} else if (command) {
command(attrs);
} else {
commands[`create${capitalize(item.name)}`](attrs);
@@ -435,7 +437,8 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
if (
item.name &&
!commands[item.name] &&
!commands[`create${capitalize(item.name)}`]
!commands[`create${capitalize(item.name)}`] &&
item.name !== "noop"
) {
return false;
}
@@ -12,7 +12,7 @@ export type Props = {
/** Callback when the item is clicked */
onClick: (event: React.SyntheticEvent) => void;
/** An optional icon for the item */
icon?: React.ReactElement;
icon?: React.ReactNode;
/** The title of the item */
title: React.ReactNode;
/** An optional subtitle for the item */
+16 -10
View File
@@ -99,22 +99,28 @@ export default class BlockMenuExtension extends Suggestion {
];
}
private handleClose = action((insertNewLine: boolean) => {
const { view } = this.editor;
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
});
widget = ({ rtl }: WidgetProps) => {
const { props, view } = this.editor;
const { props } = this.editor;
return (
<BlockMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action((insertNewLine) => {
if (insertNewLine) {
const transaction = view.state.tr.split(view.state.selection.to);
view.dispatch(transaction);
view.focus();
}
this.state.open = false;
})}
onClose={this.handleClose}
uploadFile={props.uploadFile}
onFileUploadStart={props.onFileUploadStart}
onFileUploadStop={props.onFileUploadStop}
+1
View File
@@ -33,6 +33,7 @@ export default class EmojiMenuExtension extends Suggestion {
widget = ({ rtl }: WidgetProps) => (
<EmojiMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
+1
View File
@@ -21,6 +21,7 @@ export default class MentionMenuExtension extends Suggestion {
widget = ({ rtl }: WidgetProps) => (
<MentionMenu
rtl={rtl}
trigger={this.options.trigger}
isActive={this.state.open}
search={this.state.query}
onClose={action(() => {
@@ -1,18 +1,29 @@
import { action, observable } from "mobx";
import { toggleMark } from "prosemirror-commands";
import { Slice } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import {
EditorState,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import * as React from "react";
import { v4 } from "uuid";
import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import Extension, { WidgetProps } from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
import { recreateTransform } from "@shared/editor/lib/prosemirror-recreate-transform";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
/**
* Checks if the HTML string is likely coming from Dropbox Paper.
@@ -61,13 +72,26 @@ function parseSingleIframeSrc(html: string) {
}
export default class PasteHandler extends Extension {
state: {
open: boolean;
query: string;
pastedText: string;
} = observable({
open: false,
query: "",
pastedText: "",
});
get name() {
return "paste-handler";
}
private key = new PluginKey(this.name);
get plugins() {
return [
new Plugin({
key: this.key,
props: {
transformPastedHTML(html: string) {
if (isDropboxPaper(html)) {
@@ -107,23 +131,6 @@ export default class PasteHandler extends Extension {
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
function insertLink(href: string, title?: string) {
// If it's not an embed and there is no text selected just go ahead and insert the
// link directly
const transaction = view.state.tr
.insertText(
title ?? href,
state.selection.from,
state.selection.to
)
.addMark(
state.selection.from,
state.selection.to + (title ?? href).length,
state.schema.marks.link.create({ href })
);
view.dispatch(transaction);
}
// If the users selection is currently in a code block then paste
// as plain text, ignore all formatting and HTML content.
if (isInCode(state)) {
@@ -152,28 +159,6 @@ export default class PasteHandler extends Extension {
return true;
}
// Is this link embeddable? Create an embed!
const { embeds } = this.editor.props;
if (
embeds &&
this.editor.commands.embed &&
!isInCode(state) &&
!isInList(state)
) {
for (const embed of embeds) {
if (!embed.matchOnInput) {
continue;
}
const matches = embed.matcher(text);
if (matches) {
this.editor.commands.embed({
href: text,
});
return true;
}
}
}
// Is the link a link to a document? If so, we can grab the title and insert it.
if (isDocumentUrl(text)) {
const slug = parseDocumentSlug(text);
@@ -209,7 +194,7 @@ export default class PasteHandler extends Extension {
hasEmoji ? document.icon + " " : ""
}${document.titleWithDefault}`;
insertLink(`${document.path}${hash}`, title);
this.insertLink(`${document.path}${hash}`, title);
}
}
})
@@ -217,11 +202,11 @@ export default class PasteHandler extends Extension {
if (view.isDestroyed) {
return;
}
insertLink(text);
this.insertLink(text);
});
}
} else {
insertLink(text);
this.insertLink(text);
}
return true;
@@ -323,10 +308,170 @@ export default class PasteHandler extends Extension {
return false;
},
},
state: {
init: () => DecorationSet.empty,
apply: (tr, set) => {
let mapping = tr.mapping;
// See if the transaction adds or removes any placeholders
const meta = tr.getMeta(this.key);
const hasDecorations = set.find().length;
// We only want a single paste placeholder at a time, so if we're adding a new
// placeholder we can just return a new DecorationSet and avoid mapping logic.
if (meta?.add) {
const { from, to, id } = meta.add;
const decorations = [
Decoration.inline(
from,
to,
{
class: "paste-placeholder",
},
{
id,
}
),
];
return DecorationSet.create(tr.doc, decorations);
}
if (hasDecorations && (isRemoteTransaction(tr) || meta)) {
try {
mapping = recreateTransform(tr.before, tr.doc, {
complexSteps: true,
wordDiffs: false,
simplifyDiff: true,
}).mapping;
} catch (err) {
// eslint-disable-next-line no-console
console.warn("Failed to recreate transform: ", err);
}
}
set = set.map(mapping, tr.doc);
if (meta?.remove) {
const { id } = meta.remove;
const decorations = set.find(
undefined,
undefined,
(spec) => spec.id === id
);
return set.remove(decorations);
}
return set;
},
},
}),
];
}
/** Tracks whether the Shift key is currently held down */
private shiftKey = false;
private showPasteMenu = action((text: string) => {
this.state.pastedText = text;
this.state.open = true;
});
private hidePasteMenu = action(() => {
this.state.open = false;
});
private insertLink(href: string, title?: string) {
const { view } = this.editor;
const { state } = view;
const { from } = state.selection;
const to = from + (title ?? href).length;
const transaction = view.state.tr
.insertText(title ?? href, state.selection.from, state.selection.to)
.addMark(from, to, state.schema.marks.link.create({ href }))
.setMeta(this.key, { add: { from, to, id: href } });
view.dispatch(transaction);
this.showPasteMenu(href);
}
private insertEmbed = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
this.editor.commands.embed({
href: this.state.pastedText,
});
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
if (result) {
view.dispatch(
state.tr.setMeta(this.key, {
remove: { id: this.state.pastedText },
})
);
}
};
private findPlaceholder = (
state: EditorState,
id: string
): [number, number] | null => {
const decos = this.key.getState(state) as DecorationSet;
const found = decos?.find(undefined, undefined, (spec) => spec.id === id);
return found?.length ? [found[0].from, found[0].to] : null;
};
private handleSelect = (item: MenuItem) => {
switch (item.name) {
case "noop": {
this.hidePasteMenu();
this.removePlaceholder();
break;
}
case "embed": {
this.hidePasteMenu();
this.insertEmbed();
break;
}
default:
break;
}
};
keys() {
return {
Backspace: () => {
this.hidePasteMenu();
return false;
},
"Mod-z": () => {
this.hidePasteMenu();
return false;
},
};
}
widget = ({ rtl }: WidgetProps) => (
<PasteMenu
rtl={rtl}
embeds={this.editor.props.embeds}
pastedText={this.state.pastedText}
isActive={this.state.open}
search={this.state.query}
onClose={this.hidePasteMenu}
onSelect={this.handleSelect}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
import emojiRegex from "emoji-regex";
/**
* Hook to calculate text statistics
* @param text The string to calculate statistics for
* @param selectedText A substring of the text to calculate statistics for
* @returns An object containing total and selected statistics
*/
export function useTextStats(text: string, selectedText: string = "") {
const numTotalWords = countWords(text);
const regex = emojiRegex();
const matches = Array.from(text.matchAll(regex));
return {
total: {
words: numTotalWords,
characters: text.length,
emoji: matches.length ?? 0,
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
},
selected: {
words: countWords(selectedText),
characters: selectedText.length,
},
};
}
function countWords(text: string): number {
const t = text.trim();
// Hyphenated words are counted as two words
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
+6 -1
View File
@@ -6,11 +6,16 @@ import Field from "./decorators/Field";
class ApiKey extends ParanoidModel {
static modelName = "ApiKey";
/** The user chosen name of the API key. */
/** The human-readable name of this API key */
@Field
@observable
name: string;
/** A list of scopes that this API key has access to. If empty, the key has full access. */
@Field
@observable
scope?: string[];
/** An optional datetime that the API key expires. */
@Field
@observable
+22 -6
View File
@@ -22,6 +22,7 @@ type Props = {
function ApiKeyNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [scope, setScope] = React.useState("");
const [expiryType, setExpiryType] = React.useState<ExpiryType>(
ExpiryType.Week
);
@@ -51,6 +52,10 @@ function ApiKeyNew({ onSubmit }: Props) {
setName(event.target.value);
}, []);
const handleScopeChange = React.useCallback((event) => {
setScope(event.target.value);
}, []);
const handleExpiryTypeChange = React.useCallback((value: string) => {
const expiry = value as ExpiryType;
setExpiryType(expiry);
@@ -70,6 +75,7 @@ function ApiKeyNew({ onSubmit }: Props) {
await apiKeys.create({
name,
expiresAt: expiresAt?.toISOString(),
scope: scope ? scope.split(" ") : undefined,
});
toast.success(
t(
@@ -83,20 +89,16 @@ function ApiKeyNew({ onSubmit }: Props) {
setIsSaving(false);
}
},
[t, name, expiresAt, onSubmit, apiKeys]
[t, name, scope, expiresAt, onSubmit, apiKeys]
);
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t(
`Name your key something that will help you to remember it's use in the future, for example "local development" or "continuous integration".`
)}
</Text>
<Flex column>
<Input
type="text"
label={t("Name")}
placeholder={t("Development")}
onChange={handleNameChange}
value={name}
minLength={ApiKeyValidation.minNameLength}
@@ -105,6 +107,20 @@ function ApiKeyNew({ onSubmit }: Props) {
autoFocus
flex
/>
<Input
type="text"
label={t("Scopes")}
placeholder="documents.info"
onChange={handleScopeChange}
value={scope}
flex
/>
<Text type="secondary" size="small" as="p">
{t(
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access"
)}
.
</Text>
<Flex align="center" gap={16}>
<StyledExpirySelect
ariaLabel={t("Expiration")}
+23 -25
View File
@@ -217,33 +217,31 @@ function SharedDocumentScene(props: Props) {
);
}
const SharedDocument = ({
shareId,
response,
}: {
shareId?: string;
response: Response;
}) => {
const { setDocument } = useDocumentContext();
const SharedDocument = observer(
({ shareId, response }: { shareId?: string; response: Response }) => {
const { hasHeadings, setDocument } = useDocumentContext();
if (!response.document) {
return null;
if (!response.document) {
return null;
}
const tocPosition = hasHeadings
? response.team?.tocPosition ?? TOCPosition.Left
: false;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
}
const tocPosition = response.team?.tocPosition ?? TOCPosition.Left;
setDocument(response.document);
return (
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
tocPosition={tocPosition}
readOnly
/>
);
};
);
const Content = styled(Text)`
color: ${s("textSecondary")};
@@ -54,7 +54,7 @@ function CommentThread({
collapseThreshold = 5,
collapseNumDisplayed = 3,
}: Props) {
const [focusedOnMount] = React.useState(focused);
const [scrollOnMount] = React.useState(focused && !window.location.hash);
const { editor } = useDocumentContext();
const { comments } = useStores();
const topRef = React.useRef<HTMLDivElement>(null);
@@ -165,7 +165,7 @@ function CommentThread({
React.useEffect(() => {
if (focused) {
if (focusedOnMount) {
if (scrollOnMount) {
setTimeout(() => {
if (!topRef.current) {
return;
@@ -209,7 +209,7 @@ function CommentThread({
isMarkVisible ? 0 : sidebarAppearDuration
);
}
}, [focused, focusedOnMount, thread.id]);
}, [focused, scrollOnMount, thread.id]);
return (
<Thread
@@ -243,7 +243,7 @@ function CommentThreadItem({
onOpen={disableScroll}
onClose={enableScroll}
size={28}
rounded
$rounded
/>
) : undefined
}
@@ -264,7 +264,7 @@ function CommentThreadItem({
onSelect={handleAddReaction}
onOpen={disableScroll}
onClose={enableScroll}
rounded
$rounded
/>
</>
)}
@@ -302,7 +302,7 @@ const ResolveButton = ({
comment,
onResolve: () => onUpdate({ resolved: true }),
})}
rounded
$rounded
>
<DoneIcon size={22} outline />
</Action>
@@ -340,10 +340,10 @@ const Body = styled.form`
border-radius: 2px;
`;
const Action = styled.span<{ rounded?: boolean }>`
const Action = styled.span<{ $rounded?: boolean }>`
color: ${s("textSecondary")};
${(props) =>
props.rounded &&
props.$rounded &&
css`
border-radius: 50%;
`}
+9 -7
View File
@@ -89,7 +89,7 @@ type Props = WithTranslation &
revision?: Revision;
readOnly: boolean;
shareId?: string;
tocPosition?: TOCPosition;
tocPosition?: TOCPosition | false;
onCreateLink?: (
params: Properties<Document>,
nested?: boolean
@@ -438,13 +438,15 @@ class DocumentScene extends React.Component<Props> {
const embedsDisabled =
(team && team.documentEmbeds === false) || document.embedsDisabled;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
const tocPos =
tocPosition ??
((team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left);
const showContents =
tocPos &&
(isShare
? ui.tocVisible !== false
: !document.isTemplate && ui.tocVisible === true);
const multiplayerEditor =
!document.isArchived && !document.isDeleted && !revision && !isShare;
@@ -622,7 +624,7 @@ class DocumentScene extends React.Component<Props> {
type MainProps = {
fullWidth: boolean;
tocPosition: TOCPosition;
tocPosition: TOCPosition | false;
};
const Main = styled.div<MainProps>`
@@ -650,7 +652,7 @@ const Main = styled.div<MainProps>`
type ContentsContainerProps = {
docFullWidth: boolean;
position: TOCPosition;
position: TOCPosition | false;
};
const ContentsContainer = styled.div<ContentsContainerProps>`
@@ -668,7 +670,7 @@ const ContentsContainer = styled.div<ContentsContainerProps>`
type EditorContainerProps = {
docFullWidth: boolean;
showContents: boolean;
tocPosition: TOCPosition;
tocPosition: TOCPosition | false;
};
const EditorContainer = styled.div<EditorContainerProps>`
+11 -4
View File
@@ -96,6 +96,7 @@ function DocumentHeader({
const ref = React.useRef<HTMLDivElement | null>(null);
const size = useComponentSize(ref);
const isMobile = isMobileMedia || size.width < 700;
const isShare = !!shareId;
// We cache this value for as long as the component is mounted so that if you
// apply a template there is still the option to replace it until the user
@@ -109,8 +110,13 @@ function DocumentHeader({
}, [onSave]);
const handleToggle = React.useCallback(() => {
ui.set({ tocVisible: !ui.tocVisible });
}, [ui]);
// Public shares, by default, show ToC on load.
if (isShare && ui.tocVisible === undefined) {
ui.set({ tocVisible: false });
} else {
ui.set({ tocVisible: !ui.tocVisible });
}
}, [ui, isShare]);
const context = useActionContext({
activeDocumentId: document?.id,
@@ -120,7 +126,6 @@ function DocumentHeader({
const { isDeleted, isTemplate } = document;
const isTemplateEditable = can.update && isTemplate;
const canToggleEmbeds = team?.documentEmbeds;
const isShare = !!shareId;
const showContents =
(ui.tocVisible === true && !document.isTemplate) ||
(isShare && ui.tocVisible !== false);
@@ -212,7 +217,9 @@ function DocumentHeader({
hasSidebar={sharedTree && sharedTree.children?.length > 0}
left={
isMobile ? (
<TableOfContentsMenu />
hasHeadings ? (
<TableOfContentsMenu />
) : null
) : (
<PublicBreadcrumb
documentId={document.id}
+1 -27
View File
@@ -1,4 +1,3 @@
import emojiRegex from "emoji-regex";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -20,6 +19,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
import { useTextStats } from "~/hooks/useTextStats";
import InsightsMenu from "~/menus/InsightsMenu";
import { documentPath } from "~/utils/routeHelpers";
import Sidebar from "./SidebarLayout";
@@ -213,32 +213,6 @@ function Insights() {
);
}
function useTextStats(text: string, selectedText: string) {
const numTotalWords = countWords(text);
const regex = emojiRegex();
const matches = Array.from(text.matchAll(regex));
return {
total: {
words: numTotalWords,
characters: text.length,
emoji: matches.length ?? 0,
readingTime: Math.max(1, Math.floor(numTotalWords / 200)),
},
selected: {
words: countWords(selectedText),
characters: selectedText.length,
},
};
}
function countWords(text: string): number {
const t = text.trim();
// Hyphenated words are counted as two words
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
const ListSpacing = styled("div")`
margin-top: -0.5em;
margin-bottom: 0.5em;
+38 -6
View File
@@ -457,13 +457,32 @@ function KeyboardShortcuts() {
},
],
},
{
title: t("Triggers"),
items: [
{
shortcut: "@",
label: t("Mention user or document"),
},
{
shortcut: ":",
label: t("Emoji"),
},
{
shortcut: "/",
label: t("Insert block"),
},
],
},
],
[t]
);
const [searchTerm, setSearchTerm] = React.useState("");
const normalizedSearchTerm = searchTerm.toLocaleLowerCase();
const handleChange = React.useCallback((event) => {
setSearchTerm(event.target.value);
}, []);
const handleKeyDown = React.useCallback((event) => {
if (event.currentTarget.value && event.key === "Escape") {
event.preventDefault();
@@ -471,17 +490,20 @@ function KeyboardShortcuts() {
setSearchTerm("");
}
}, []);
return (
<Flex column>
<InputSearch
onChange={handleChange}
onKeyDown={handleKeyDown}
value={searchTerm}
/>
<StickySearch>
<InputSearch
onChange={handleChange}
onKeyDown={handleKeyDown}
value={searchTerm}
/>
</StickySearch>
{categories.map((category, x) => {
const filtered = searchTerm
? category.items.filter((item) =>
item.label.toLowerCase().includes(searchTerm.toLowerCase())
item.label.toLocaleLowerCase().includes(normalizedSearchTerm)
)
: category.items;
@@ -509,6 +531,16 @@ function KeyboardShortcuts() {
);
}
const StickySearch = styled.div`
position: sticky;
top: -16px;
z-index: 1;
padding: 16px;
margin: -16px;
background: ${s("background")};
border-radius: 8px;
`;
const Header = styled.h2`
font-size: 15px;
font-weight: 500;
+2 -2
View File
@@ -78,7 +78,7 @@ const Profile = () => {
/>
</SettingRow>
<SettingRow
border={false}
border={env.EMAIL_ENABLED}
label={t("Name")}
name="name"
description={t(
@@ -95,7 +95,7 @@ const Profile = () => {
</SettingRow>
{env.EMAIL_ENABLED && (
<SettingRow label={t("Email address")} name="email">
<SettingRow border={false} label={t("Email address")} name="email">
<Input
type="email"
value={user.email}
@@ -10,6 +10,7 @@ import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import Time from "~/components/Time";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useUserLocale from "~/hooks/useUserLocale";
import ApiKeyMenu from "~/menus/ApiKeyMenu";
@@ -35,7 +36,7 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
&middot;{" "}
</Text>
{apiKey.lastActiveAt && (
<Text type={"tertiary"}>
<Text type="tertiary">
{t("Last used")} <Time dateTime={apiKey.lastActiveAt} addSuffix />{" "}
&middot;{" "}
</Text>
@@ -44,7 +45,20 @@ const ApiKeyListItem = ({ apiKey }: Props) => {
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
: t("No expiry")}
{apiKey.scope && <> &middot; </>}
</Text>
{apiKey.scope && (
<Tooltip
content={apiKey.scope.map((s) => (
<>
{s}
<br />
</>
))}
>
<Text type="tertiary">{t("Restricted scope")}</Text>
</Tooltip>
)}
</>
);
+1 -1
View File
@@ -106,6 +106,6 @@ export default class GroupsStore extends Store<Group> {
function queriedGroups(groups: Group[], query: string) {
return groups.filter((group) =>
group.name.toLowerCase().match(query.toLowerCase())
group.name.toLocaleLowerCase().includes(query.toLocaleLowerCase())
);
}
+6 -6
View File
@@ -26,7 +26,7 @@ export type MenuItemButton = {
visible?: boolean;
selected?: boolean;
disabled?: boolean;
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuItemWithChildren = {
@@ -38,7 +38,7 @@ export type MenuItemWithChildren = {
hover?: boolean;
items: MenuItem[];
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuSeparator = {
@@ -59,7 +59,7 @@ export type MenuInternalLink = {
visible?: boolean;
selected?: boolean;
disabled?: boolean;
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuExternalLink = {
@@ -70,7 +70,7 @@ export type MenuExternalLink = {
selected?: boolean;
disabled?: boolean;
level?: number;
icon?: React.ReactElement;
icon?: React.ReactNode;
};
export type MenuItem =
@@ -108,7 +108,7 @@ export type Action = {
/** Higher number is higher in results, default is 0. */
priority?: number;
iconInContextMenu?: boolean;
icon?: React.ReactElement | React.FC;
icon?: React.ReactNode;
placeholder?: ((context: ActionContext) => string) | string;
selected?: (context: ActionContext) => boolean;
visible?: (context: ActionContext) => boolean;
@@ -127,7 +127,7 @@ export type CommandBarAction = {
shortcut: string[];
keywords: string;
placeholder?: string;
icon?: React.ReactElement;
icon?: React.ReactNode;
perform?: () => void;
children?: string[];
parent?: string;
+18 -18
View File
@@ -53,25 +53,25 @@
"@aws-sdk/s3-presigned-post": "3.693.0",
"@aws-sdk/s3-request-presigner": "3.693.0",
"@aws-sdk/signature-v4-crt": "^3.693.0",
"@babel/core": "^7.24.7",
"@babel/plugin-proposal-decorators": "^7.24.7",
"@babel/plugin-transform-class-properties": "^7.24.7",
"@babel/plugin-transform-destructuring": "^7.24.8",
"@babel/core": "^7.26.7",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
"@babel/plugin-transform-destructuring": "^7.25.9",
"@babel/plugin-transform-regenerator": "^7.25.9",
"@babel/preset-env": "^7.25.8",
"@babel/preset-env": "^7.26.7",
"@babel/preset-react": "^7.26.3",
"@benrbray/prosemirror-math": "^0.2.2",
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "^4.12.2",
"@css-inline/css-inline-wasm": "^0.14.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.2.1",
"@fast-csv/parse": "^5.0.2",
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@getoutline/react-roving-tabindex": "^3.2.4",
"@hocuspocus/extension-throttle": "1.1.2",
@@ -85,11 +85,11 @@
"@renderlesskit/react": "^0.11.0",
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.5",
"@tanstack/react-virtual": "^3.10.9",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.0",
"@types/mailparser": "^3.4.4",
"@types/mailparser": "^3.4.5",
"@types/sanitize-filename": "^1.6.3",
"@vitejs/plugin-react": "^3.1.0",
"addressparser": "^1.0.1",
@@ -147,7 +147,7 @@
"koa-sslify": "5.0.1",
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.1",
"mailparser": "^3.7.2",
"mammoth": "^1.8.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
@@ -199,7 +199,7 @@
"react-dom": "^17.0.2",
"react-dropzone": "^11.7.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.53.1",
"react-hook-form": "^7.54.2",
"react-i18next": "^12.3.1",
"react-medium-image-zoom": "5.2.10",
"react-merge-refs": "^2.1.1",
@@ -241,7 +241,7 @@
"utility-types": "^3.11.0",
"uuid": "^8.3.2",
"validator": "13.12.0",
"vite": "^5.4.11",
"vite": "^5.4.12",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.13.0",
"ws": "^7.5.10",
@@ -254,7 +254,7 @@
},
"devDependencies": {
"@babel/cli": "^7.26.4",
"@babel/preset-typescript": "^7.24.1",
"@babel/preset-typescript": "^7.26.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.13",
"@testing-library/react": "^12.0.0",
@@ -289,7 +289,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.14.2",
"@types/node": "20.17.16",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -353,7 +353,7 @@
"prettier": "^2.8.8",
"react-refresh": "^0.14.2",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.4.1",
"rollup-plugin-webpack-stats": "^2.0.1",
"terser": "^5.36.0",
"typescript": "^5.7.2",
"vite-plugin-static-copy": "^0.17.0",
+16 -10
View File
@@ -35,8 +35,8 @@ type Props = Optional<
};
export default async function documentCreator({
title = "",
text = "",
title,
text,
icon,
color,
state,
@@ -101,14 +101,20 @@ export default async function documentCreator({
fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth,
icon: templateDocument ? templateDocument.icon : icon,
color: templateDocument ? templateDocument.color : color,
title: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.title : title,
user
),
text: TextHelper.replaceTemplateVariables(
templateDocument ? templateDocument.text : text,
user
),
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),
+4 -1
View File
@@ -191,9 +191,12 @@ export default abstract class BaseEmail<
const parsedFrom = addressparser(env.SMTP_FROM_EMAIL)[0];
const domain = parsedFrom.address.split("@")[1];
const customFromName = this.fromName?.(props);
return {
name: this.fromName?.(props) ?? parsedFrom.name,
name: customFromName
? `${customFromName} via ${env.APP_NAME}`
: parsedFrom.name,
address:
env.isCloudHosted &&
this.category === EmailMessageCategory.Authentication
@@ -103,7 +103,7 @@ export default class CommentCreatedEmail extends BaseEmail<
: `${commentText.slice(0, MAX_SUBJECT_CONTENT)}...`;
return `${parentComment ? "Re: " : ""}New comment on “${
document.title
document.titleWithDefault
}” - ${trimmedText}`;
}
@@ -136,7 +136,7 @@ export default class CommentCreatedEmail extends BaseEmail<
}: Props): string {
return `
${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${
document.title
document.titleWithDefault
}"${collection?.name ? `in the ${collection.name} collection` : ""}.
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
@@ -164,10 +164,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} {isReply ? "replied to a thread in" : "commented on"}{" "}
<a href={threadLink}>{document.title}</a>{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection?.name ? `in the ${collection.name} collection` : ""}.
</p>
{body && (
@@ -92,7 +92,7 @@ export default class CommentMentionedEmail extends BaseEmail<
}
protected subject({ document }: Props) {
return `Mentioned you in “${document.title}`;
return `Mentioned you in “${document.titleWithDefault}`;
}
protected preview({ actorName }: Props): string {
@@ -111,7 +111,7 @@ export default class CommentMentionedEmail extends BaseEmail<
collection,
}: Props): string {
return `
${actorName} mentioned you in a comment on "${document.title}"${
${actorName} mentioned you in a comment on "${document.titleWithDefault}"${
collection.name ? `in the ${collection.name} collection` : ""
}.
@@ -139,10 +139,10 @@ Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} mentioned you in a comment on{" "}
<a href={threadLink}>{document.title}</a>{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection.name ? `in the ${collection.name} collection` : ""}.
</p>
{body && (
@@ -92,7 +92,7 @@ export default class CommentResolvedEmail extends BaseEmail<
}
protected subject({ document }: Props) {
return `Resolved a comment thread in “${document.title}`;
return `Resolved a comment thread in “${document.titleWithDefault}`;
}
protected preview({ actorName }: Props): string {
@@ -110,7 +110,7 @@ export default class CommentResolvedEmail extends BaseEmail<
commentId,
collection,
}: Props): string {
const t1 = `${actorName} resolved a comment thread on "${document.title}"`;
const t1 = `${actorName} resolved a comment thread on "${document.titleWithDefault}"`;
const t2 = collection.name ? ` in the ${collection.name} collection` : "";
const t3 = `Open Thread: ${teamUrl}${document.url}?commentId=${commentId}`;
return `${t1}${t2}.\n\n${t3}`;
@@ -136,10 +136,10 @@ export default class CommentResolvedEmail extends BaseEmail<
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} resolved a comment on{" "}
<a href={threadLink}>{document.title}</a>{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection.name ? `in the ${collection.name} collection` : ""}.
</p>
{body && (
@@ -92,7 +92,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
}
protected subject({ document }: Props) {
return `Mentioned you in “${document.title}`;
return `Mentioned you in “${document.titleWithDefault}`;
}
protected preview({ actorName }: Props): string {
@@ -116,7 +116,7 @@ export default class DocumentMentionedEmail extends BaseEmail<
return `
You were mentioned
${actorName} mentioned you in the document “${document.title}”.
${actorName} mentioned you in the document “${document.titleWithDefault}”.
Open Document: ${teamUrl}${document.url}
`;
@@ -137,7 +137,7 @@ Open Document: ${teamUrl}${document.url}
<Heading>You were mentioned</Heading>
<p>
{actorName} mentioned you in the document{" "}
<a href={documentLink}>{document.title}</a>.
<a href={documentLink}>{document.titleWithDefault}</a>.
</p>
{body && (
<>
@@ -114,7 +114,7 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
}
protected subject({ document, eventType }: Props) {
return `${document.title}${this.eventName(eventType)}`;
return `${document.titleWithDefault}${this.eventName(eventType)}`;
}
protected preview({ actorName, eventType }: Props): string {
@@ -144,9 +144,9 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
const eventName = this.eventName(eventType);
return `
"${document.title}" ${eventName}
"${document.titleWithDefault}" ${eventName}
${actorName} ${eventName} the document "${document.title}"${
${actorName} ${eventName} the document "${document.titleWithDefault}"${
collection?.name ? `, in the ${collection.name} collection` : ""
}.
@@ -176,11 +176,11 @@ Open Document: ${teamUrl}${document.url}
<Body>
<Heading>
{document.title} {eventName}
{document.titleWithDefault} {eventName}
</Heading>
<p>
{actorName} {eventName} the document{" "}
<a href={documentLink}>{document.title}</a>
<a href={documentLink}>{document.titleWithDefault}</a>
{collection?.name ? <>, in the {collection.name} collection</> : ""}
.
</p>
@@ -53,7 +53,7 @@ export default class DocumentSharedEmail extends BaseEmail<
}
protected subject({ actorName, document }: Props) {
return `${actorName} shared “${document.title}” with you`;
return `${actorName} shared “${document.titleWithDefault}” with you`;
}
protected preview({ actorName }: Props): string {
@@ -66,7 +66,7 @@ export default class DocumentSharedEmail extends BaseEmail<
protected renderAsText({ actorName, teamUrl, document }: Props): string {
return `
${actorName} shared “${document.title}” with you.
${actorName} shared “${document.titleWithDefault}” with you.
View Document: ${teamUrl}${document.path}
`;
@@ -87,10 +87,10 @@ View Document: ${teamUrl}${document.path}
<Header />
<Body>
<Heading>{document.title}</Heading>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} invited you to {permission} the{" "}
<a href={documentUrl}>{document.title}</a> document.
<a href={documentUrl}>{document.titleWithDefault}</a> document.
</p>
<p>
<Button href={documentUrl}>View Document</Button>
+7 -1
View File
@@ -80,7 +80,13 @@ export default function auth(options: AuthenticationOptions = {}) {
}
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
throw AuthenticationError("Invalid API key");
throw AuthenticationError("API key is expired");
}
if (!apiKey.canAccess(ctx.request.url)) {
throw AuthenticationError(
"API key does not have access to this resource"
);
}
user = await User.findByPk(apiKey.userId, {
@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.addColumn("apiKeys", "scope", {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
}, { transaction });
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async transaction => {
await queryInterface.removeColumn("apiKeys", "scope", { transaction });
});
},
};
+63 -5
View File
@@ -4,26 +4,26 @@ import ApiKey from "./ApiKey";
describe("#ApiKey", () => {
describe("match", () => {
test("should match an API secret", async () => {
it("should match an API secret", async () => {
const apiKey = await buildApiKey();
expect(ApiKey.match(apiKey.value!)).toBe(true);
expect(ApiKey.match(`${randomstring.generate(38)}`)).toBe(true);
});
test("should not match non secrets", async () => {
it("should not match non secrets", async () => {
expect(ApiKey.match("123")).toBe(false);
expect(ApiKey.match("1234567890")).toBe(false);
});
});
describe("lastActiveAt", () => {
test("should update lastActiveAt", async () => {
it("should update lastActiveAt", async () => {
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
});
test("should not update lastActiveAt within 5 minutes", async () => {
it("should not update lastActiveAt within 5 minutes", async () => {
const apiKey = await buildApiKey();
await apiKey.updateActiveAt();
expect(apiKey.lastActiveAt).toBeTruthy();
@@ -35,7 +35,7 @@ describe("#ApiKey", () => {
});
describe("findByToken", () => {
test("should find by hash", async () => {
it("should find by hash", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
@@ -44,4 +44,62 @@ describe("#ApiKey", () => {
expect(found?.last4).toEqual(apiKey.value!.slice(-4));
});
});
describe("canAccess", () => {
it("should return true for all resources if no scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(true);
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(true);
});
it("should return false if no matching scope", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.info"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
expect(apiKey.canAccess("/api/apiKeys.list")).toBe(false);
});
it("should allow wildcard methods", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/documents.*"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(true);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow wildcard namespaces", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/*.info"],
});
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
it("should allow multiple scopes", async () => {
const apiKey = await buildApiKey({
name: "Dev",
scope: ["/api/*.info", "/api/collections.list"],
});
expect(apiKey.canAccess("/api/shares.info")).toBe(true);
expect(apiKey.canAccess("/api/documents.info")).toBe(true);
expect(apiKey.canAccess("/api/collections.list")).toBe(true);
expect(apiKey.canAccess("/api/documents.create")).toBe(false);
expect(apiKey.canAccess("/api/collections.create")).toBe(false);
});
});
});
+32
View File
@@ -1,4 +1,5 @@
import crypto from "crypto";
import { Matches } from "class-validator";
import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes, Op } from "sequelize";
@@ -31,6 +32,7 @@ class ApiKey extends ParanoidModel<
static eventNamespace = "api_keys";
/** The human-readable name of this API key */
@Length({
min: ApiKeyValidation.minNameLength,
max: ApiKeyValidation.maxNameLength,
@@ -39,6 +41,13 @@ class ApiKey extends ParanoidModel<
@Column
name: string;
/** A space-separated list of scopes that this API key has access to */
@Matches(/[\/\.\w\s]*/, {
each: true,
})
@Column(DataType.ARRAY(DataType.STRING))
scope: string[] | null;
/** @deprecated The plain text value of the API key, removed soon. */
@Unique
@Column
@@ -59,10 +68,12 @@ class ApiKey extends ParanoidModel<
@SkipChangeset
last4: string;
/** The date and time when this API key will expire */
@IsDate
@Column
expiresAt: Date | null;
/** The date and time when this API key was last used */
@IsDate
@Column
@SkipChangeset
@@ -156,6 +167,27 @@ class ApiKey extends ParanoidModel<
return this.save({ silent: true });
};
/** Checks if the API key has access to the given path */
canAccess = (path: string) => {
if (!this.scope) {
return true;
}
const resource = path.split("/").pop() ?? "";
const [namespace, method] = resource.split(".");
return this.scope.some((scope) => {
const [scopeNamespace, scopeMethod] = scope
.replace("/api/", "")
.split(".");
return (
scope.startsWith("/api/") &&
(namespace === scopeNamespace || scopeNamespace === "*") &&
(method === scopeMethod || scopeMethod === "*")
);
});
};
}
export default ApiKey;
-2
View File
@@ -36,8 +36,6 @@ class Attachment extends IdModel<
InferAttributes<Attachment>,
Partial<InferCreationAttributes<Attachment>>
> {
static eventNamespace = "attachments";
@Length({
max: 4096,
msg: "key must be 4096 characters or less",
-2
View File
@@ -166,8 +166,6 @@ class Collection extends ParanoidModel<
InferAttributes<Collection>,
Partial<InferCreationAttributes<Collection>>
> {
static eventNamespace = "collections";
@SimpleLength({
min: 10,
max: 10,
-2
View File
@@ -42,8 +42,6 @@ class Comment extends ParanoidModel<
InferAttributes<Comment>,
Partial<InferCreationAttributes<Comment>>
> {
static eventNamespace = "comments";
@TextLength({
max: CommentValidation.maxLength,
msg: `Comment must be less than ${CommentValidation.maxLength} characters`,
-2
View File
@@ -254,8 +254,6 @@ class Document extends ArchivableModel<
InferAttributes<Document>,
Partial<InferCreationAttributes<Document>>
> {
static eventNamespace = "documents";
@SimpleLength({
min: 10,
max: 10,
-2
View File
@@ -60,8 +60,6 @@ class Group extends ParanoidModel<
InferAttributes<Group>,
Partial<InferCreationAttributes<Group>>
> {
static eventNamespace = "groups";
@Length({ min: 0, max: 255, msg: "name must be be 255 characters or less" })
@NotContainsUrl
@Column
+34 -40
View File
@@ -21,6 +21,7 @@ import {
AfterDestroy,
} from "sequelize-typescript";
import { CollectionPermission, DocumentPermission } from "@shared/types";
import { APIContext } from "@server/types";
import Collection from "./Collection";
import Document from "./Document";
import Group from "./Group";
@@ -157,7 +158,7 @@ class GroupMembership extends ParanoidModel<
permission: membership.permission,
createdById: membership.createdById,
},
{ transaction }
{ transaction, hooks: false }
)
)
);
@@ -211,20 +212,12 @@ class GroupMembership extends ParanoidModel<
@AfterCreate
static async publishAddGroupEventAfterCreate(
model: GroupMembership,
context: HookContext
context: APIContext["context"]
) {
const data = { membershipId: model.id, isNew: true };
const ctxWithData = {
...context,
event: { ...context.event, data },
} as HookContext;
if (model.collectionId) {
await Collection.insertEvent("add_group", model, ctxWithData);
} else {
await Document.insertEvent("add_group", model, ctxWithData);
}
await model.insertEvent(context, "add_group", {
membershipId: model.id,
isNew: true,
});
}
@AfterUpdate
@@ -257,20 +250,12 @@ class GroupMembership extends ParanoidModel<
@AfterUpdate
static async publishAddGroupEventAfterUpdate(
model: GroupMembership,
context: HookContext
context: APIContext["context"]
) {
const data = { membershipId: model.id, isNew: false };
const ctxWithData = {
...context,
event: { ...context.event, data },
} as HookContext;
if (model.collectionId) {
await Collection.insertEvent("add_group", model, ctxWithData);
} else {
await Document.insertEvent("add_group", model, ctxWithData);
}
await model.insertEvent(context, "add_group", {
membershipId: model.id,
isNew: false,
});
}
@AfterDestroy
@@ -295,20 +280,11 @@ class GroupMembership extends ParanoidModel<
@AfterDestroy
static async publishRemoveGroupEvent(
model: GroupMembership,
context: HookContext
context: APIContext["context"]
) {
const data = { membershipId: model.id };
const ctxWithData = {
...context,
event: { ...context.event, data },
} as HookContext;
if (model.collectionId) {
await Collection.insertEvent("remove_group", model, ctxWithData);
} else {
await Document.insertEvent("remove_group", model, ctxWithData);
}
await model.insertEvent(context, "remove_group", {
membershipId: model.id,
});
}
/**
@@ -368,10 +344,28 @@ class GroupMembership extends ParanoidModel<
},
{
transaction,
hooks: false,
}
);
}
}
private async insertEvent(
ctx: APIContext["context"],
name: string,
data: Record<string, unknown>
) {
const hookContext = {
...ctx,
event: { name, data, create: true },
} as HookContext;
if (this.collectionId) {
await Collection.insertEvent(name, this, hookContext);
} else {
await Document.insertEvent(name, this, hookContext);
}
}
}
export default GroupMembership;
+3 -21
View File
@@ -7,12 +7,10 @@ import {
Table,
DataType,
Scopes,
AfterCreate,
AfterDestroy,
} from "sequelize-typescript";
import Group from "./Group";
import User from "./User";
import Model, { type HookContext } from "./base/Model";
import Model from "./base/Model";
import Fix from "./decorators/Fix";
@DefaultScope(() => ({
@@ -44,6 +42,8 @@ class GroupUser extends Model<
InferAttributes<GroupUser>,
Partial<InferCreationAttributes<GroupUser>>
> {
static eventNamespace = "groups";
@BelongsTo(() => User, "userId")
user: User;
@@ -68,24 +68,6 @@ class GroupUser extends Model<
get modelId() {
return this.groupId;
}
// hooks
@AfterCreate
public static async publishAddUserEvent(
model: GroupUser,
context: HookContext
) {
await Group.insertEvent("add_user", model, context);
}
@AfterDestroy
public static async publishRemoveUserEvent(
model: GroupUser,
context: HookContext
) {
await Group.insertEvent("remove_user", model, context);
}
}
export default GroupUser;
-2
View File
@@ -20,8 +20,6 @@ class Pin extends IdModel<
InferAttributes<Pin>,
Partial<InferCreationAttributes<Pin>>
> {
static eventNamespace = "pins";
@Length({
max: 256,
msg: `index must be 256 characters or less`,
+3 -3
View File
@@ -18,10 +18,10 @@ import {
Table,
} from "sequelize-typescript";
import { createContext } from "@server/context";
import { APIContext } from "@server/types";
import Comment from "./Comment";
import User from "./User";
import IdModel from "./base/IdModel";
import { type HookContext } from "./base/Model";
import Fix from "./decorators/Fix";
import Length from "./validators/Length";
@@ -57,7 +57,7 @@ class Reaction extends IdModel<
@AfterCreate
public static async addReactionToCommentCache(
model: Reaction,
ctx: HookContext &
ctx: APIContext["context"] &
FindOrCreateOptions<Attributes<Reaction>, CreationAttributes<Reaction>>
) {
const { transaction } = ctx;
@@ -109,7 +109,7 @@ class Reaction extends IdModel<
@AfterDestroy
public static async removeReactionFromCommentCache(
model: Reaction,
ctx: HookContext & InstanceDestroyOptions
ctx: APIContext["context"] & InstanceDestroyOptions
) {
const { transaction } = ctx;
-2
View File
@@ -83,8 +83,6 @@ class Share extends IdModel<
InferAttributes<Share>,
Partial<InferCreationAttributes<Share>>
> {
static eventNamespace = "shares";
@Column
published: boolean;
-2
View File
@@ -19,8 +19,6 @@ class Star extends IdModel<
InferAttributes<Star>,
Partial<InferCreationAttributes<Star>>
> {
static eventNamespace = "stars";
@Length({
max: 256,
msg: `index must be 256 characters or less`,
-2
View File
@@ -28,8 +28,6 @@ class Subscription extends ParanoidModel<
InferAttributes<Subscription>,
Partial<InferCreationAttributes<Subscription>>
> {
static eventNamespace = "subscriptions";
@BelongsTo(() => User, "userId")
user: User;
+3 -3
View File
@@ -144,12 +144,12 @@ class UserMembership extends IdModel<
options: SaveOptions
) {
const { transaction } = options;
const groupMemberships = await this.findAll({
const userMemberships = await this.findAll({
where,
transaction,
});
await Promise.all(
groupMemberships.map((membership) =>
userMemberships.map((membership) =>
this.create(
{
documentId: document.id,
@@ -158,7 +158,7 @@ class UserMembership extends IdModel<
permission: membership.permission,
createdById: membership.createdById,
},
{ transaction }
{ transaction, hooks: false }
)
)
);
+6 -10
View File
@@ -19,7 +19,7 @@ import {
AfterRestore,
AfterUpdate,
AfterUpsert,
BeforeCreate,
BeforeSave,
Model as SequelizeModel,
} from "sequelize-typescript";
import Logger from "@server/logging/Logger";
@@ -47,8 +47,7 @@ class Model<
TCreationAttributes extends {} = TModelAttributes
> extends SequelizeModel<TModelAttributes, TCreationAttributes> {
/**
* The namespace to use for events, if none is provided an event will not be created
* during the migration period. In the future this may default to the table name.
* The namespace to use for events - defaults to the table name if none is provided.
*/
static eventNamespace: string | undefined;
@@ -67,7 +66,6 @@ class Model<
create: true,
},
};
this.cacheChangeset();
return this.save({ ...options, ...hookContext });
}
@@ -87,7 +85,6 @@ class Model<
},
};
this.set(keys);
this.cacheChangeset();
return this.save(hookContext);
}
@@ -162,8 +159,8 @@ class Model<
return this.create(values, hookContext);
}
@BeforeCreate
static async beforeCreateEvent<T extends Model>(model: T) {
@BeforeSave
static async beforeSaveEvent<T extends Model>(model: T) {
model.cacheChangeset();
}
@@ -219,11 +216,10 @@ class Model<
model: T,
context: HookContext
) {
const namespace = this.eventNamespace;
const namespace = this.eventNamespace ?? this.tableName;
const models = this.sequelize!.models;
// If no namespace is defined, don't create an event
if (!namespace || !context.event?.create) {
if (!context.event?.create) {
return;
}
+1 -4
View File
@@ -1,5 +1,4 @@
import env from "@server/env";
import { IncorrectEditionError } from "@server/errors";
import { User, Team } from "@server/models";
import Model from "@server/models/base/Model";
@@ -97,9 +96,7 @@ export function isTeamMutable(_actor: User, _model?: Model | null) {
*/
export function isCloudHosted() {
if (!env.isCloudHosted) {
throw IncorrectEditionError(
"Functionality is not available in this edition"
);
return false;
}
return true;
}
+1
View File
@@ -5,6 +5,7 @@ export default function presentApiKey(apiKey: ApiKey) {
id: apiKey.id,
userId: apiKey.userId,
name: apiKey.name,
scope: apiKey.scope,
value: apiKey.value,
last4: apiKey.last4,
createdAt: apiKey.createdAt,
+21
View File
@@ -40,6 +40,27 @@ describe("#apiKeys.create", () => {
expect(body.data.lastActiveAt).toBeNull();
});
it("should allow creating an api key with scopes", async () => {
const user = await buildUser();
const res = await server.post("/api/apiKeys.create", {
body: {
token: user.getJwtToken(),
name: "My API Key",
scope: ["/api/documents.list", "*.info", "users.*"],
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.scope).toEqual([
"/api/documents.list",
"/api/*.info",
"/api/users.*",
]);
});
it("should require authentication", async () => {
const res = await server.post("/api/apiKeys.create");
expect(res.status).toEqual(401);
+2 -1
View File
@@ -19,7 +19,7 @@ router.post(
validate(T.APIKeysCreateSchema),
transaction(),
async (ctx: APIContext<T.APIKeysCreateReq>) => {
const { name, expiresAt } = ctx.input.body;
const { name, scope, expiresAt } = ctx.input.body;
const { user } = ctx.state.auth;
authorize(user, "createApiKey", user.team);
@@ -28,6 +28,7 @@ router.post(
name,
userId: user.id,
expiresAt,
scope: scope?.map((s) => (s.startsWith("/api/") ? s : `/api/${s}`)),
});
ctx.body = {
+2
View File
@@ -7,6 +7,8 @@ export const APIKeysCreateSchema = BaseSchema.extend({
name: z.string(),
/** API Key expiry date */
expiresAt: z.coerce.date().optional(),
/** A list of scopes that this API key has access to */
scope: z.array(z.string()).optional(),
}),
});
+14 -16
View File
@@ -211,24 +211,22 @@ router.post(
authorize(user, "update", collection);
authorize(user, "read", group);
const [membership, created] = await GroupMembership.findOrCreateWithCtx(
ctx,
{
where: {
collectionId: id,
groupId,
},
defaults: {
permission,
createdById: user.id,
},
lock: transaction.LOCK.UPDATE,
}
);
const [membership, created] = await GroupMembership.findOrCreate({
where: {
collectionId: id,
groupId,
},
defaults: {
permission,
createdById: user.id,
},
lock: transaction.LOCK.UPDATE,
...ctx.context,
});
if (!created) {
membership.permission = permission;
await membership.saveWithCtx(ctx);
await membership.save(ctx.context);
}
const groupMemberships = [presentGroupMembership(membership)];
@@ -273,7 +271,7 @@ router.post(
ctx.throw(400, "This Group is not a part of the collection");
}
await membership.destroyWithCtx(ctx);
await membership.destroy(ctx.context);
ctx.body = {
success: true,
+3 -2
View File
@@ -381,12 +381,13 @@ router.post(
authorize(user, "comment", document);
authorize(user, "addReaction", comment);
await Reaction.findOrCreateWithCtx(ctx, {
await Reaction.findOrCreate({
where: {
emoji,
userId: user.id,
commentId: id,
},
...ctx.context,
});
ctx.body = {
@@ -429,7 +430,7 @@ router.post(
});
authorize(user, "delete", reaction);
await reaction.destroyWithCtx(ctx);
await reaction.destroy(ctx.context);
ctx.body = {
success: true,
@@ -6,6 +6,7 @@ import {
StatusFilter,
UserRole,
} from "@shared/types";
import { TextHelper } from "@shared/utils/TextHelper";
import { createContext } from "@server/context";
import {
Document,
@@ -3357,6 +3358,127 @@ describe("#documents.import", () => {
});
describe("#documents.create", () => {
it("should replace template variables when a doc is created from a template", async () => {
const user = await buildUser();
const template = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "Created by user {author} on {date}",
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
templateId: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual(
TextHelper.replaceTemplateVariables(template.title, user)
);
expect(body.data.text).toEqual(
TextHelper.replaceTemplateVariables(template.text, user)
);
});
it("should retain template variables when a template is created from another template", async () => {
const user = await buildUser();
const template = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "Created by user {author} on {date}",
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
templateId: template.id,
template: true,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual(template.title);
expect(body.data.text).toEqual(template.text);
});
it("should create a document with empty title if no title is explicitly passed", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
text: "hello",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual("");
});
it("should use template title when doc is supposed to be created using the template and title is not explicitly passed", async () => {
const user = await buildUser();
const template = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
text: "template text",
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
templateId: template.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual(template.title);
expect(body.data.text).toEqual(template.text);
});
it("should override template title when doc title is explicitly passed", async () => {
const user = await buildUser();
const template = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
title: "template title",
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
templateId: template.id,
title: "doc title",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toEqual("doc title");
});
it("should override template text when doc text is explicitly passed", async () => {
const user = await buildUser();
const template = await buildDocument({
userId: user.id,
teamId: user.teamId,
template: true,
text: "template text",
});
const res = await server.post("/api/documents.create", {
body: {
token: user.getJwtToken(),
templateId: template.id,
text: "doc text",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.text).toEqual("doc text");
});
it("should fail for invalid collectionId", async () => {
const user = await buildUser();
const res = await server.post("/api/documents.create", {
+18 -17
View File
@@ -6,6 +6,7 @@ import JSZip from "jszip";
import Router from "koa-router";
import escapeRegExp from "lodash/escapeRegExp";
import has from "lodash/has";
import isNil from "lodash/isNil";
import remove from "lodash/remove";
import uniq from "lodash/uniq";
import mime from "mime-types";
@@ -1644,7 +1645,9 @@ router.post(
const document = await documentCreator({
id,
title,
text: await TextHelper.replaceImagesWithAttachments(ctx, text, user),
text: !isNil(text)
? await TextHelper.replaceImagesWithAttachments(ctx, text, user)
: text,
icon,
color,
createdAt,
@@ -1841,20 +1844,18 @@ router.post(
authorize(user, "update", document);
authorize(user, "read", group);
const [membership, created] = await GroupMembership.findOrCreateWithCtx(
ctx,
{
where: {
documentId: id,
groupId,
},
defaults: {
permission: permission || user.defaultDocumentPermission,
createdById: user.id,
},
lock: transaction.LOCK.UPDATE,
}
);
const [membership, created] = await GroupMembership.findOrCreate({
where: {
documentId: id,
groupId,
},
defaults: {
permission: permission || user.defaultDocumentPermission,
createdById: user.id,
},
lock: transaction.LOCK.UPDATE,
...ctx.context,
});
if (!created && permission) {
membership.permission = permission;
@@ -1862,7 +1863,7 @@ router.post(
// disconnect from the source if the permission is manually updated
membership.sourceId = null;
await membership.saveWithCtx(ctx);
await membership.save(ctx.context);
}
ctx.body = {
@@ -1907,7 +1908,7 @@ router.post(
rejectOnEmpty: true,
});
await membership.destroyWithCtx(ctx);
await membership.destroy(ctx.context);
ctx.body = {
success: true,
+2 -2
View File
@@ -331,10 +331,10 @@ export const DocumentsCreateSchema = BaseSchema.extend({
id: z.string().uuid().optional(),
/** Document title */
title: z.string().default(""),
title: z.string().optional(),
/** Document text */
text: z.string().default(""),
text: z.string().optional(),
/** Icon displayed alongside doc title */
icon: zodIconType().optional(),
+13 -9
View File
@@ -261,15 +261,19 @@ router.post(
const group = await Group.findByPk(id, { transaction });
authorize(actor, "update", group);
const [groupUser] = await GroupUser.findOrCreateWithCtx(ctx, {
where: {
groupId: group.id,
userId: user.id,
const [groupUser] = await GroupUser.findOrCreateWithCtx(
ctx,
{
where: {
groupId: group.id,
userId: user.id,
},
defaults: {
createdById: actor.id,
},
},
defaults: {
createdById: actor.id,
},
});
{ name: "add_user" }
);
groupUser.user = user;
@@ -308,7 +312,7 @@ router.post(
lock: transaction.LOCK.UPDATE,
});
await groupUser?.destroyWithCtx(ctx);
await groupUser?.destroyWithCtx(ctx, { name: "remove_user" });
ctx.body = {
data: {
+7 -7
View File
@@ -67,7 +67,7 @@ export class DocumentConverter {
}
public static async htmlToMarkdown(content: Buffer | string) {
if (content instanceof Buffer) {
if (typeof content !== "string") {
content = content.toString("utf8");
}
@@ -117,26 +117,26 @@ export class DocumentConverter {
}
public static fileToMarkdown(content: Buffer | string) {
if (content instanceof Buffer) {
if (typeof content !== "string") {
content = content.toString("utf8");
}
return content;
}
public static async confluenceToMarkdown(value: Buffer | string) {
if (value instanceof Buffer) {
value = value.toString("utf8");
public static async confluenceToMarkdown(content: Buffer | string) {
if (typeof content !== "string") {
content = content.toString("utf8");
}
// We're only supporting the output from Confluence here, regular Word documents should call
// into the docxToMarkdown importer. See: https://jira.atlassian.com/browse/CONFSERVER-38237
if (!value.includes("Content-Type: multipart/related")) {
if (!content.includes("Content-Type: multipart/related")) {
throw FileImportError("Unsupported Word file");
}
// Confluence "Word" documents are actually just multi-part email messages, so we can use
// mailparser to parse the content.
const parsed = await simpleParser(value);
const parsed = await simpleParser(content);
if (!parsed.html) {
throw FileImportError("Unsupported Word file (No content found)");
}
+2 -2
View File
@@ -31,9 +31,9 @@ export type EmbedProps = {
};
const Img = styled(Image)`
border-radius: 2px;
border-radius: 3px;
background: #fff;
box-shadow: 0 0 0 1px #fff;
box-shadow: 0 0 0 1px ${(props) => props.theme.divider};
margin: 3px;
width: 18px;
height: 18px;
+3 -1
View File
@@ -30,7 +30,9 @@ export default class Mention extends Node {
get schema(): NodeSpec {
return {
attrs: {
type: {},
type: {
default: MentionType.User,
},
label: {},
modelId: {},
actorId: {
+1 -1
View File
@@ -15,7 +15,7 @@ export enum TableLayout {
type Section = ({ t }: { t: TFunction }) => string;
export type MenuItem = {
icon?: React.ReactElement;
icon?: React.ReactNode;
name?: string;
title?: string;
section?: Section;
+10 -1
View File
@@ -197,6 +197,7 @@
"Install now": "Install now",
"Deleted Collection": "Deleted Collection",
"Unpin": "Unpin",
"{{ minutes }}m read": "{{ minutes }}m read",
"Select a location to copy": "Select a location to copy",
"Document copied": "Document copied",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
@@ -419,6 +420,8 @@
"Profile picture": "Profile picture",
"Create a new doc": "Create a new doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Embed": "Embed",
"Add column after": "Add column after",
"Add column before": "Add column before",
"Add row after": "Add row after",
@@ -567,7 +570,8 @@
"invited you to": "invited you to",
"Choose a date": "Choose a date",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".",
"Scopes": "Scopes",
"Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access": "Space-separated scopes restrict the access of this API key to specific parts of the API. Leave blank for full access",
"Expiration": "Expiration",
"Never expires": "Never expires",
"7 days": "7 days",
@@ -759,6 +763,10 @@
"LaTeX block": "LaTeX block",
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
"Continue with Email": "Continue with Email",
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
@@ -821,6 +829,7 @@
"by {{ name }}": "by {{ name }}",
"Last used": "Last used",
"No expiry": "No expiry",
"Restricted scope": "Restricted scope",
"API key copied to clipboard": "API key copied to clipboard",
"Copied": "Copied",
"Revoking": "Revoking",
+51 -51
View File
@@ -12,7 +12,7 @@
"Star": "Stjerne",
"Unstar": "Fjern stjerne",
"Archive": "Arkiv",
"Archive collection": "Archive collection",
"Archive collection": "Arkiver samling",
"Collection archived": "Samling arkivert",
"Archiving": "Arkivering",
"Archiving this collection will also archive all documents within it. Documents from the collection will no longer be visible in search results.": "Ved å arkivere denne samlingen vil du også arkivere alle dokumenter inkludert. Dokumenter fra samlingen vil ikke lenger være synlige i søkeresultater.",
@@ -94,9 +94,9 @@
"Insights": "Innsikt",
"Disable viewer insights": "Deaktiver leserinnsikt",
"Enable viewer insights": "Aktiver leserinnsikt",
"Leave document": "Leave document",
"You have left the shared document": "You have left the shared document",
"Could not leave document": "Could not leave document",
"Leave document": "Forlat dokument",
"You have left the shared document": "Du har forlatt det delte dokumentet",
"Could not leave document": "Kunne ikke forlate dokument",
"Home": "Hjem",
"Drafts": "Utkast",
"Trash": "Søppel",
@@ -199,9 +199,9 @@
"Unpin": "Løsne",
"Select a location to copy": "Velg en plassering å kopiere",
"Document copied": "Dokument kopiert",
"Couldnt copy the document, try again?": "Couldnt copy the document, try again?",
"Couldnt copy the document, try again?": "Kunne ikke kopiere dokumentet, prøv igjen?",
"Include nested documents": "Inkluder underdokumenter",
"Copy to <em>{{ location }}</em>": "Copy to <em>{{ location }}</em>",
"Copy to <em>{{ location }}</em>": "Kopier til <em>{{ location }}</em>",
"Search collections & documents": "Søk i samlinger og dokumenter",
"No results found": "Ingen resultater funnet",
"Untitled": "Uten tittel",
@@ -303,14 +303,14 @@
"Unknown": "Ukjent",
"Mark all as read": "Merk alle som lest",
"You're all caught up": "Du er helt oppdatert",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
"Add reaction": "Add reaction",
"Reaction picker": "Reaction picker",
"Could not load reactions": "Could not load reactions",
"Reaction": "Reaction",
"{{ username }} reacted with {{ emoji }}": "{{ username }} reagerte med {{ emoji }}",
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} og {{ secondUsername }} reagerte med {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} og {{ count }} andre reagerte med {{ emoji }}",
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} og {{ count }} andre reagerte med {{ emoji }}",
"Add reaction": "Legg til reaksjon",
"Reaction picker": "Reaksjonsvelger",
"Could not load reactions": "Kunne ikke laste reaksjoner",
"Reaction": "Reaksjon",
"Results": "Resultater",
"No results for {{query}}": "Ingen resultater for {{query}}",
"Manage": "Administrer",
@@ -355,8 +355,8 @@
"Anyone with the link can access because the parent document, <2>{{documentTitle}}</2>, is shared": "Alle med koblingen har tilgang fordi det overordnede dokumentet, <2>{{documentTitle}}</2>, deles",
"Allow anyone with the link to access": "Tillat alle med lenken å få tilgang til",
"Publish to internet": "Publiser til internett",
"Search engine indexing": "Search engine indexing",
"Disable this setting to discourage search engines from indexing the page": "Disable this setting to discourage search engines from indexing the page",
"Search engine indexing": "Søkemotorindeksering",
"Disable this setting to discourage search engines from indexing the page": "Deaktiver denne innstillingen for å fraråde søkemotorer til å indeksere denne siden",
"Nested documents are not shared on the web. Toggle sharing to enable access, this will be the default behavior in the future": "Underdokumenter deles ikke på nettet. Aktiver deling for å tillate tilgang, dette vil være standard oppførsel i fremtiden",
"{{ userName }} was added to the document": "{{ userName }} ble lagt til i dokumentet",
"{{ count }} people added to the document": "{{ count }} person er lagt til i dokumentet",
@@ -364,8 +364,8 @@
"{{ count }} groups added to the document": "{{ count }} grupper er lagt til i dokumentet",
"{{ count }} groups added to the document_plural": "{{ count }} grupper er lagt til i dokumentene",
"Logo": "Logo",
"Archived collections": "Archived collections",
"Change permissions?": "Change permissions?",
"Archived collections": "Arkiverte samlinger",
"Change permissions?": "Endre tillatelser?",
"New doc": "Nytt dokument",
"You can't reorder documents in an alphabetically sorted collection": "Du kan ikke omorganisere dokumenter i en alfabetisk sortert samling",
"Empty": "Tom",
@@ -399,12 +399,12 @@
"Are you sure you want to suspend {{ userName }}? Suspended users will be prevented from logging in.": "Er du sikker på at du vil suspendere {{ userName }}? Suspendering vil hindre brukeren i å logge inn.",
"New name": "Nytt navn",
"Name can't be empty": "Navn kan ikke være tomt",
"Check your email to verify the new address.": "Check your email to verify the new address.",
"The email will be changed once verified.": "The email will be changed once verified.",
"You will receive an email to verify your new address. It must be unique in the workspace.": "You will receive an email to verify your new address. It must be unique in the workspace.",
"A confirmation email will be sent to the new address before it is changed.": "A confirmation email will be sent to the new address before it is changed.",
"New email": "New email",
"Email can't be empty": "Email can't be empty",
"Check your email to verify the new address.": "Sjekk e-posten din for å verifisere den nye adressen.",
"The email will be changed once verified.": "E-posten vil bli endret når den er blitt verifisert.",
"You will receive an email to verify your new address. It must be unique in the workspace.": "Du vil motta en e-post for å verifisere din nye e-postadresse. Den må være unik i arbeidsområdet.",
"A confirmation email will be sent to the new address before it is changed.": "En e-post med bekreftelse vil bli sendt til den nye e-postadressen før den endres.",
"New email": "Ny e-post",
"Email can't be empty": "E-post kan ikke være tom",
"Your import completed": "Importen din er fullført",
"Previous match": "Forrige treff",
"Next match": "Neste treff",
@@ -418,7 +418,7 @@
"Replace all": "Erstatt alle",
"Profile picture": "Profilbilde",
"Create a new doc": "Lag et nytt dokument",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} vil ikke bli varslet, da de ikke har tilgang til dette dokumentet",
"Add column after": "Legg til kolonne etter",
"Add column before": "Sett inn kolonne før",
"Add row after": "Legg til rad etter",
@@ -447,11 +447,11 @@
"Italic": "Kursiv",
"Sorry, that link wont work for this embed type": "Beklager, den lenken vil ikke fungere for denne innebygde typen",
"File attachment": "Filvedlegg",
"Enter a link": "Enter a link",
"Enter a link": "Skriv inn en lenke",
"Big heading": "Stor overskrift",
"Medium heading": "Middels overskrift",
"Small heading": "Liten overskrift",
"Extra small heading": "Extra small heading",
"Extra small heading": "Ekstra liten overskrift",
"Heading": "Overskrift",
"Divider": "Skillelinje",
"Image": "Bilde",
@@ -480,7 +480,7 @@
"Sort ascending": "Sorter stigende",
"Sort descending": "Sorter synkende",
"Table": "Tabell",
"Export as CSV": "Export as CSV",
"Export as CSV": "Eksporter som CSV",
"Toggle header": "Vis/skjul topp",
"Math inline (LaTeX)": "Matematikk i linje (LaTeX)",
"Math block (LaTeX)": "Matematikkblokk (LaTeX)",
@@ -500,7 +500,7 @@
"Could not import file": "Kunne ikke importere fil",
"Unsubscribed from document": "Avsluttet abonnement fra dokument",
"Account": "Konto",
"API Keys": "API Keys",
"API Keys": "API-nøkler",
"Details": "Detaljer",
"Security": "Sikkerhet",
"Features": "Funksjoner",
@@ -518,8 +518,8 @@
"Export collection": "Eksporter samling",
"Rename": "Gi nytt navn",
"Sort in sidebar": "Sorter i sidemeny",
"A-Z sort": "A-Z sort",
"Z-A sort": "Z-A sort",
"A-Z sort": "A-Å sortering",
"Z-A sort": "Å-A sortering",
"Manual sort": "Manuell sortering",
"Comment options": "Kommentaralternativer",
"Show document menu": "Vis dokumentmeny",
@@ -547,7 +547,7 @@
"Headings you add to the document will appear here": "Overskrifter du legger til i dokumentet vil vises her",
"Table of contents": "Innholdsfortegnelse",
"Change name": "Endre navn",
"Change email": "Change email",
"Change email": "Endre e-post",
"Suspend user": "Suspendere bruker",
"An error occurred while sending the invite": "En feil oppstod under sending av invitasjonen",
"User options": "Brukeralternativer",
@@ -562,11 +562,11 @@
"created the collection": "opprettet samlingen",
"mentioned you in": "nevnte deg i",
"left a comment on": "la igjen en kommentar på",
"resolved a comment on": "resolved a comment on",
"resolved a comment on": "løste en kommentar på",
"shared": "delt",
"invited you to": "inviterte deg til",
"Choose a date": "Velg en dato",
"API key created. Please copy the value now as it will not be shown again.": "API key created. Please copy the value now as it will not be shown again.",
"API key created. Please copy the value now as it will not be shown again.": "API-nøkkel opprettet. Kopier verdien nå, da den ikke vil bli vist igjen.",
"Name your key something that will help you to remember it's use in the future, for example \"local development\" or \"continuous integration\".": "Gi nøkkelen et navn som hjelper deg å huske dens bruk i fremtiden, for eksempel \"lokale utvikling\", \"produksjon\", eller \"kontinuerlig integrasjon\".",
"Expiration": "Utløper",
"Never expires": "Utløper aldri",
@@ -605,13 +605,13 @@
"Upload image": "Last opp bilde",
"No resolved comments": "Ingen løste kommentarer",
"No comments yet": "Ingen kommentarer enda",
"New comments": "New comments",
"Sort comments": "Sort comments",
"Most recent": "Most recent",
"Order in doc": "Order in doc",
"Resolved": "Resolved",
"Show {{ count }} reply": "Show {{ count }} reply",
"Show {{ count }} reply_plural": "Show {{ count }} replies",
"New comments": "Nye kommentarer",
"Sort comments": "Sorter kommentarer",
"Most recent": "Siste",
"Order in doc": "Sortering i dokument",
"Resolved": "Løst",
"Show {{ count }} reply": "Vis {{ count }} svar",
"Show {{ count }} reply_plural": "Vis {{ count }} svar",
"Error updating comment": "Feil ved oppdatering av kommentar",
"Document restored": "Dokument gjenopprettet",
"Images are still uploading.\nAre you sure you want to discard them?": "Bilder lastes fortsatt opp.\nEr du sikker på at du vil forkaste dem?",
@@ -782,7 +782,7 @@
"This workspace has been suspended. Please contact support to restore access.": "Dette arbeidsområdet har blitt suspendert. Vennligst kontakt støtte for å gjenopprette tilgangen.",
"Authentication failed this login method was disabled by a team admin.": "Autentisering mislyktes denne innloggingsmetoden ble deaktivert av en teamadministrator.",
"The workspace you are trying to join requires an invite before you can create an account.<1></1>Please request an invite from your workspace admin and try again.": "Arbeidsområdet du prøver å bli med krever en invitasjon før du kan opprette en konto.<1></1>Vennligst be om en invitasjon fra din arbeidsområdeadministrator og prøv igjen.",
"Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.",
"Sorry, an unknown error occurred.": "Beklager, det oppstod en ukjent feil.",
"Login": "Logg inn",
"Error": "Feil",
"Failed to load configuration.": "Kunne ikke laste konfigurasjonen.",
@@ -817,8 +817,8 @@
"Please try again or contact support if the problem persists": "Prøv igjen eller kontakt kundestøtte hvis problemet vedvarer",
"No documents found for your search filters.": "Ingen dokumenter funnet for søkefiltrene dine.",
"API": "API",
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
"by {{ name }}": "by {{ name }}",
"API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.": "API-nøkler kan brukes til å autentisere API-et og gi programmatisk kontroll over\n dataen i ditt arbeidsområdet. For flere detaljer, se <em>utviklerdokumentasjonen</em>.",
"by {{ name }}": "av {{ name }}",
"Last used": "Sist brukt",
"No expiry": "Ingen utløpsdato",
"API key copied to clipboard": "API-nøkkel kopiert til utklippstavlen",
@@ -869,7 +869,7 @@
"Search people": "Søk i personer",
"No people matching your search": "Ingen personer matcher søket ditt",
"No people left to add": "Ingen flere personer å legge til",
"Date created": "Date created",
"Date created": "Dato opprettet",
"Upload": "Last opp",
"How does this work?": "Hvordan fungerer dette?",
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "Du kan importere en zip-fil som tidligere ble eksportert fra JSON-alternativet i en annen instans. I {{ appName }}, åpne <em>Eksport</em> i innstillingssidestolpen og klikk på <em>Eksporter Data</em>.",
@@ -927,7 +927,7 @@
"Commenting": "Kommentering",
"When enabled team members can add comments to documents.": "Når aktivert, kan teammedlemmer legge til kommentarer i dokumenter.",
"Create a group": "Opprett en gruppe",
"Could not load groups": "Could not load groups",
"Could not load groups": "Kunne ikke laste grupper",
"New group": "Ny gruppe",
"Groups can be used to organize and manage the people on your team.": "Grupper kan brukes til å organisere og administrere personene på teamet ditt.",
"No groups have been created yet": "Ingen grupper har blitt opprettet ennå",
@@ -939,7 +939,7 @@
"Import pages from a Confluence instance": "Importer sider fra en Confluence-instans",
"Enterprise": "Bedrift",
"Recent imports": "Nylige importer",
"Could not load members": "Could not load members",
"Could not load members": "Kunne ikke laste medlemmer",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Alle som er logget inn i {{appName}} vises her. Det er mulig at det er andre brukere som har tilgang via {{signinMethods}}, men som ikke har logget inn enda.",
"Receive a notification whenever a new document is published": "Motta et varsel hver gang et nytt dokument blir publisert",
"Document updated": "Dokument oppdatert",
@@ -948,7 +948,7 @@
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Motta en varsling når et dokument du abonnerer på eller en tråd du har deltatt i mottar en kommentar",
"Mentioned": "Nevnt",
"Receive a notification when someone mentions you in a document or comment": "Motta en varsling når noen nevner deg i et dokument eller en kommentar",
"Receive a notification when a comment thread you were involved in is resolved": "Receive a notification when a comment thread you were involved in is resolved",
"Receive a notification when a comment thread you were involved in is resolved": "Motta et varsel når en kommentartråd du deltok i er løst",
"Collection created": "Samling opprettet",
"Receive a notification whenever a new collection is created": "Motta en varsling når en ny samling opprettes",
"Invite accepted": "Invitasjon akseptert",
@@ -982,8 +982,8 @@
"When enabled, documents have a separate editing mode. When disabled, documents are always editable when you have permission.": "Når aktivert, har dokumenter en separat redigeringsmodus. Når deaktivert, er dokumenter alltid redigerbare når du har tillatelse.",
"Remember previous location": "Husk tidligere plassering",
"Automatically return to the document you were last viewing when the app is re-opened.": "Gå automatisk tilbake til dokumentet du sist så på når appen åpnes på nytt.",
"Smart text replacements": "Smart text replacements",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.",
"Smart text replacements": "Smarttekst-erstatninger",
"Auto-format text by replacing shortcuts with symbols, dashes, smart quotes, and other typographical elements.": "Autoformater tekst ved å erstatte snarveier med symboler, bindestreker, smarte anførselstegn og andre tegn.",
"You may delete your account at any time, note that this is unrecoverable": "Du kan slette kontoen din når som helst, merk at dette er uopprettelig",
"Profile saved": "Profil lagret",
"Profile picture updated": "Profilbilde oppdatert",
@@ -1022,7 +1022,7 @@
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Legg til URL-en for din egen-hostede draw.io-installasjon her for å aktivere automatisk innbedning av diagrammer i dokumenter.",
"Grist deployment": "Grist-implementering",
"Add your self-hosted grist installation URL here.": "Legg til URL-en for din egen-hostede Grist-installasjon her.",
"Could not load shares": "Could not load shares",
"Could not load shares": "Kunne ikke laste delinger",
"Sharing is currently disabled.": "Deling er for øyeblikket deaktivert.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "Du kan globalt aktivere og deaktivere offentlig deling av dokumenter i <em>sikkerhetsinnstillingene</em>.",
"Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Dokumenter som har blitt delt er listet nedenfor. Alle som har den offentlige lenken kan få tilgang til en skrivebeskyttet versjon av dokumentet til lenken blir tilbakekalt.",
+170 -158
View File
@@ -4,95 +4,101 @@ import {
faAndroid,
faSquareJs,
faPython,
faWebAwesome,
faXTwitter,
faBluesky,
} from "@fortawesome/free-brands-svg-icons";
import {
faHeart,
faWandSparkles,
faUmbrella,
faMugHot,
faBook,
faDroplet,
faBrush,
faSnowflake,
faShop,
faShirt,
faBagShopping,
faGauge,
faMountainSun,
faPassport,
faPhoneVolume,
faNewspaper,
faNetworkWired,
faRocket,
faStarOfLife,
faSeedling,
faTrain,
faMicrochip,
faRecordVinyl,
faTrophy,
faHammer,
faRobot,
faBook,
faBrush,
faCake,
faCat,
faClapperboard,
faCompactDisc,
faCookieBite,
faCrow,
faCrown,
faCube,
faRoad,
faPuzzlePiece,
faIndustry,
faWorm,
faVault,
faUtensils,
faUserGraduate,
faUniversalAccess,
faTractor,
faTent,
faSpa,
faSocks,
faScissors,
faSailboat,
faPizzaSlice,
faPaw,
faMap,
faLaptopCode,
faKitMedical,
faFaceSurprise,
faFaceSmileWink,
faFaceSmileBeam,
faFaceMeh,
faFaceLaugh,
faFaceGrinStars,
faFaceDizzy,
faDna,
faDog,
faCrow,
faCompactDisc,
faClapperboard,
faDollarSign,
faDisplay,
faDroplet,
faFaceDizzy,
faFaceGrinStars,
faFaceLaugh,
faFaceMeh,
faFaceSmileBeam,
faFaceSmileWink,
faFaceSurprise,
faFeather,
faFish,
faCat,
faTree,
faShield,
faLaptop,
faDisplay,
faPrescription,
faWheelchairMove,
faGift,
faMagnet,
faPaintRoller,
faGamepad,
faCookieBite,
faTowerCell,
faTooth,
faDollarSign,
faSterlingSign,
faYenSign,
faPesoSign,
faRainbow,
faPenRuler,
faSwatchbook,
faStarAndCrescent,
faSolarPanel,
faUmbrellaBeach,
faGauge,
faGem,
faDna,
faCake,
faGift,
faHammer,
faHeart,
faIndustry,
faKitMedical,
faLaptop,
faLaptopCode,
faMagnet,
faMap,
faMicrochip,
faMountainSun,
faMugHot,
faNetworkWired,
faNewspaper,
faPaintRoller,
faPassport,
faPaw,
faPenRuler,
faPesoSign,
faPhoneVolume,
faPizzaSlice,
faPrescription,
faPuzzlePiece,
faRainbow,
faRecordVinyl,
faRoad,
faRobot,
faRocket,
faSailboat,
faScissors,
faSeedling,
faShield,
faShirt,
faShop,
faSnowflake,
faSocks,
faSolarPanel,
faSpa,
faStarAndCrescent,
faStarOfLife,
faSterlingSign,
faSwatchbook,
faTent,
faTooth,
faTowerCell,
faTractor,
faTrain,
faTree,
faTrophy,
faUmbrella,
faUmbrellaBeach,
faUniversalAccess,
faUserGraduate,
faUtensils,
faVault,
faWandSparkles,
faWheelchairMove,
faWorm,
faYenSign,
faHandsClapping,
faFolderClosed,
faFlaskVial,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import intersection from "lodash/intersection";
@@ -447,98 +453,104 @@ export class IconLibrary {
// Font awesome
...Object.fromEntries(
[
faHeart,
faWandSparkles,
faUmbrella,
faMugHot,
faBook,
faDroplet,
faBrush,
faSnowflake,
faShop,
faShirt,
faBagShopping,
faGauge,
faMountainSun,
faPassport,
faPhoneVolume,
faNewspaper,
faNetworkWired,
faRocket,
faStarOfLife,
faSeedling,
faTrain,
faMicrochip,
faRecordVinyl,
faTrophy,
faHammer,
faRobot,
faBook,
faBrush,
faCake,
faCat,
faClapperboard,
faCompactDisc,
faCookieBite,
faCrow,
faCrown,
faCube,
faRoad,
faPuzzlePiece,
faIndustry,
faWorm,
faVault,
faUtensils,
faUserGraduate,
faUniversalAccess,
faTractor,
faTent,
faSpa,
faSocks,
faScissors,
faSailboat,
faPizzaSlice,
faPaw,
faMap,
faLaptopCode,
faKitMedical,
faFaceSurprise,
faFaceSmileWink,
faFaceSmileBeam,
faFaceMeh,
faFaceLaugh,
faFaceGrinStars,
faFaceDizzy,
faDna,
faDog,
faCrow,
faCompactDisc,
faClapperboard,
faDollarSign,
faDisplay,
faDroplet,
faFaceDizzy,
faFaceGrinStars,
faFaceLaugh,
faFaceMeh,
faFaceSmileBeam,
faFaceSmileWink,
faFaceSurprise,
faFeather,
faFish,
faCat,
faTree,
faShield,
faLaptop,
faDisplay,
faPrescription,
faWheelchairMove,
faGift,
faMagnet,
faPaintRoller,
faFolderClosed,
faFlaskVial,
faGamepad,
faCookieBite,
faTowerCell,
faTooth,
faDollarSign,
faSterlingSign,
faYenSign,
faPesoSign,
faRainbow,
faPenRuler,
faSwatchbook,
faStarAndCrescent,
faSolarPanel,
faUmbrellaBeach,
faGauge,
faGem,
faDna,
faCake,
faGift,
faHammer,
faHandsClapping,
faHeart,
faIndustry,
faKitMedical,
faLaptop,
faLaptopCode,
faMagnet,
faMap,
faMicrochip,
faMountainSun,
faMugHot,
faNetworkWired,
faNewspaper,
faPaintRoller,
faPassport,
faPaw,
faPenRuler,
faPesoSign,
faPhoneVolume,
faPizzaSlice,
faPrescription,
faPuzzlePiece,
faRainbow,
faRecordVinyl,
faRoad,
faRobot,
faRocket,
faSailboat,
faScissors,
faSeedling,
faShield,
faShirt,
faShop,
faSnowflake,
faSocks,
faSolarPanel,
faSpa,
faStarAndCrescent,
faStarOfLife,
faSterlingSign,
faSwatchbook,
faTent,
faTooth,
faTowerCell,
faTractor,
faTrain,
faTree,
faTrophy,
faUmbrella,
faUmbrellaBeach,
faUniversalAccess,
faUserGraduate,
faUtensils,
faVault,
faWandSparkles,
faWebAwesome,
faWheelchairMove,
faWorm,
faYenSign,
faApple,
faWindows,
faAndroid,
faSquareJs,
faPython,
faXTwitter,
faBluesky,
].map((icon) => [
icon.iconName,
{
+4 -1
View File
@@ -2,13 +2,14 @@ import fs from "fs";
import path from "path";
import react from "@vitejs/plugin-react";
import browserslistToEsbuild from "browserslist-to-esbuild";
import { webpackStats } from "rollup-plugin-webpack-stats";
import webpackStats from "rollup-plugin-webpack-stats";
import { CommonServerOptions, defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { viteStaticCopy } from "vite-plugin-static-copy";
import environment from "./server/utils/environment";
let httpsConfig: CommonServerOptions["https"] | undefined;
const host = new URL(environment.URL!).hostname;
if (environment.NODE_ENV === "development") {
try {
@@ -31,6 +32,8 @@ export default () =>
port: 3001,
host: true,
https: httpsConfig,
allowedHosts: [host],
cors: true,
fs:
environment.NODE_ENV === "development"
? {
+680 -677
View File
File diff suppressed because it is too large Load Diff