Compare commits

..

65 Commits

Author SHA1 Message Date
Tom Moor cd09b0b944 fix: Image caption is not correctly centered on full-width image 2025-04-18 20:17:02 -03:00
Hemachandar 65bb3b11f3 fix: Parse emoji and url only as workspace icon (#9009)
* fix: Parse emoji and url only as workspace icon

* scope emoji regex to transform function
2025-04-18 10:45:17 -04:00
Tom Moor e1e334dd5f fix: Deleted users appear in mention menu before search query (#9003) 2025-04-17 22:57:43 -04:00
codegen-sh[bot] 6e9092bcaf #8962: Remove "Self hosted" integrations page (#9001)
* #8962: Remove "Self hosted" integrations page

* Remove unused BuildingBlocksIcon import

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-17 08:34:08 -04:00
Tom Moor 09a4b76aae fix: Users subscribed to document and collection may be notified twice (#8997)
fix: Create notifications in transaction
2025-04-17 08:08:09 -04:00
Hemachandar 5789d65bf5 Ensure iframely fallback is not executed for connected unfurl integration (#8995)
* Ensure iframely fallback is not executed for connected unfurl integration

* tsc
2025-04-16 18:22:51 -04:00
Tom Moor 03a0f54236 fix: Cannot drag-select text while editing document title in sidebar (#8991)
* fix: Cannot drag-select text while editing document title in sidebar

* Clarify isEditing parameter description
2025-04-16 18:22:43 -04:00
Tom Moor 1e7244c737 fix: Infinite loop loading page with vbnet code embed (#8987) 2025-04-16 01:51:57 +00:00
Tom Moor 96c41ce823 chore: Disable bundle-size job on forks (#8986) 2025-04-16 01:31:25 +00:00
Tom Moor 0702570b0d fix: Small modal overflow scrolling behavior (#8981)
closes #8966
2025-04-15 06:35:33 -07:00
Tom Moor 4b209a7913 fix: Full-width image control should act as toggle (#8980)
closes #8954
2025-04-15 12:22:14 +00:00
Tom Moor 6393bd02f4 fix: Cannot select divider, closes #8964 (#8979) 2025-04-15 12:13:45 +00:00
dependabot[bot] 1776aad833 chore(deps): bump the aws group with 5 updates (#8968)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-14 22:40:15 -04:00
dependabot[bot] 0c6b37cb60 chore(deps): bump react-virtualized-auto-sizer from 1.0.25 to 1.0.26 (#8969)
Bumps [react-virtualized-auto-sizer](https://github.com/bvaughn/react-virtualized-auto-sizer) from 1.0.25 to 1.0.26.
- [Release notes](https://github.com/bvaughn/react-virtualized-auto-sizer/releases)
- [Changelog](https://github.com/bvaughn/react-virtualized-auto-sizer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bvaughn/react-virtualized-auto-sizer/compare/1.0.25...1.0.26)

---
updated-dependencies:
- dependency-name: react-virtualized-auto-sizer
  dependency-version: 1.0.26
  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-04-14 22:32:45 -04:00
Tom Moor d664044579 perf: Cache promise when loading code languages (#8975) 2025-04-15 02:31:08 +00:00
dependabot[bot] b3ca434c51 chore(deps): bump prosemirror-schema-list from 1.4.1 to 1.5.1 (#8970)
Bumps [prosemirror-schema-list](https://github.com/prosemirror/prosemirror-schema-list) from 1.4.1 to 1.5.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-schema-list/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-schema-list/compare/1.4.1...1.5.1)

---
updated-dependencies:
- dependency-name: prosemirror-schema-list
  dependency-version: 1.5.1
  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-04-14 22:28:31 -04:00
dependabot[bot] 631b75def4 chore(deps-dev): bump typescript from 5.8.2 to 5.8.3 (#8972)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.8.2 to 5.8.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.2...v5.8.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 5.8.3
  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-04-14 22:28:05 -04:00
Tom Moor d183dab063 fix: Mermaid diagrams hanging (#8961) 2025-04-14 12:47:57 +00:00
Hemachandar f082da6456 fix: Reset avatar zoom, set missing text in upload button (#8949)
* fix: Reset avatar zoom, set missing text in upload button

* tiny
2025-04-13 19:15:15 -07:00
Tom Moor ad72210714 fix: Hardcode dynamic imports to avoid production issues (#8958)
* fix: Hardcode dynamic imports to avoid production issues

* tsc
2025-04-13 19:07:47 -07:00
Tom Moor 9c85b26d43 fix: Editor crashes on shared page with no user (#8956) 2025-04-13 21:48:08 +00:00
Hemachandar bf6a56849e Show GitHub issues and pull requests as mentions (#8870)
* mention issue works

* pr and loading works

* error node

* tweak mention display

* handle multiple creation error

* tidy

* store unfurl in mention attrs

* simplify mention code creation

* test fix

* base feedback

* update node when pos is available

* delete local UnfurlsStore

* use unfurl from store

* Optimize lodash isMatch import statement

* fix: Copy/paste of issue mentions
fix: Icon alignment
fix: Error and loading mentions are unselectable

* Switch order in paste menu

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-13 06:09:13 -07:00
Tom Moor 68e8b2791a fix: Line numbers flash in on load (#8948)
fix: Text color of plain text and markdown code blocks
2025-04-12 18:25:15 -07:00
Tom Moor 89db519b72 Replace embed icon (#8947) 2025-04-12 19:40:08 +00:00
codegen-sh[bot] 31c412b4a6 refactor: Convert ImageUpload component to functional (#8944)
* refactor: Convert ImageUpload component to functional

* fix: Fix linting issues by removing trailing whitespace and unused imports

* Applied automatic fixes

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-12 19:26:38 +00:00
Tom Moor 199584428a fix: Markdown copy should not occur for single node situations (#8946) 2025-04-12 12:15:52 -07:00
Tom Moor f22780e944 Move editor syntax highlighting to async (#8934)
* Move editor syntax highlighting to async, add a bunch more languages

* Remove vestigial referenecs to Prism

* fix: bundle-size job not triggering

* Add webpackStatsFile
2025-04-12 10:55:47 -07:00
dependabot[bot] a71381785c chore(deps): bump vite from 5.4.17 to 5.4.18 (#8941)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.17 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  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-04-12 10:55:27 -07:00
Tom Moor a61b53aa74 Require collection manage permissions to export (#8942) 2025-04-12 10:55:15 -07:00
Tom Moor 45f0885533 fix: bundle-size CI (#8940) 2025-04-12 10:07:48 -07:00
Hemachandar 9c9657f4cc eslint: Increase severity to error for lodash imports (#8932) 2025-04-11 17:56:28 -07:00
Tom Moor c3de0cf0ec v0.83.0 (#8928) 2025-04-10 19:53:08 -07:00
Hemachandar f7b00e72f1 Implement UnfurlsStore (#8920)
* Implement UnfurlsStore

* simplify lookup

* refetch unfurl after X elapsed time

* compute fetchedAt in client
2025-04-10 18:24:32 -07:00
Hemachandar e499881110 fix: Update collection 'documentStructure' when archived document is deleted (#8922) 2025-04-10 18:11:30 -07:00
Tom Moor 016c8c802c Finalize moving docker publish to GH actions (#8927) 2025-04-10 18:10:10 -07:00
Tom Moor d4bc189e12 fix: collectionIndexing results in teamId undefined error due to Sequelize bug (#8918) 2025-04-09 07:12:48 -07:00
dependabot[bot] 4d435cd5ec chore(deps): bump koa from 2.16.0 to 2.16.1 (#8917)
Bumps [koa](https://github.com/koajs/koa) from 2.16.0 to 2.16.1.
- [Release notes](https://github.com/koajs/koa/releases)
- [Changelog](https://github.com/koajs/koa/blob/master/History.md)
- [Commits](https://github.com/koajs/koa/compare/2.16.0...v2.16.1)

---
updated-dependencies:
- dependency-name: koa
  dependency-version: 2.16.1
  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-04-09 07:12:15 -07:00
Rahma-sbei 59c611b24f Added delay on sidebar exit (#8888)
* added delay on sidebar exit

* Fix typos in Sidebar component comments

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-09 02:44:59 +00:00
Tom Moor 1ea40c03c5 Add option to copy as plain text (#8913) 2025-04-08 19:31:34 -07:00
Tom Moor f9919e90cf fix: Allow OIDC without team name (#8911) 2025-04-08 19:10:07 -07:00
dependabot[bot] 0d09e54757 chore(deps): bump the aws group with 5 updates (#8899)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-08 18:00:01 -07:00
Tom Moor 9ce7133837 fix: Increase lock timeout for calculating document diff (#8902) 2025-04-08 17:59:54 -07:00
dependabot[bot] 01a5ff031a chore(deps): bump sonner from 1.7.1 to 1.7.4 (#8896)
Bumps [sonner](https://github.com/emilkowalski/sonner) from 1.7.1 to 1.7.4.
- [Release notes](https://github.com/emilkowalski/sonner/releases)
- [Commits](https://github.com/emilkowalski/sonner/commits)

---
updated-dependencies:
- dependency-name: sonner
  dependency-version: 1.7.4
  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-04-08 10:38:45 -04:00
dependabot[bot] 5659aeb360 chore(deps): bump vite from 5.4.16 to 5.4.17 (#8903)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.16 to 5.4.17.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.17/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.17/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.17
  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-04-08 10:35:10 -04:00
dependabot[bot] d300e34447 chore(deps): bump prosemirror-inputrules from 1.4.0 to 1.5.0 (#8897)
Bumps [prosemirror-inputrules](https://github.com/prosemirror/prosemirror-inputrules) from 1.4.0 to 1.5.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-inputrules/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-inputrules/compare/1.4.0...1.5.0)

---
updated-dependencies:
- dependency-name: prosemirror-inputrules
  dependency-version: 1.5.0
  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-04-07 20:07:38 -07:00
dependabot[bot] a4040a93a2 chore(deps-dev): bump @types/node from 20.17.27 to 20.17.30 (#8898)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.17.27 to 20.17.30.
- [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-version: 20.17.30
  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-04-07 20:07:17 -07:00
dependabot[bot] c769432993 chore(deps): bump mammoth from 1.8.0 to 1.9.0 (#8900)
Bumps [mammoth](https://github.com/mwilliamson/mammoth.js) from 1.8.0 to 1.9.0.
- [Release notes](https://github.com/mwilliamson/mammoth.js/releases)
- [Changelog](https://github.com/mwilliamson/mammoth.js/blob/master/NEWS)
- [Commits](https://github.com/mwilliamson/mammoth.js/compare/1.8.0...1.9.0)

---
updated-dependencies:
- dependency-name: mammoth
  dependency-version: 1.9.0
  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-04-07 20:07:07 -07:00
Tom Moor 270bb85417 Various improvements extracted from oauth-server branch (#8901) 2025-04-07 18:40:18 +00:00
Tom Moor fe8e50da92 fix: Remove url->embed mapping in Markdown import (#8891) 2025-04-07 00:43:18 +00:00
codegen-sh[bot] 31d1f566bc #8873: Remove usage of generateAvatarUrl and logo.clearbit.com API (#8889) 2025-04-06 16:01:23 -07:00
Tom Moor f9476770ce fix: Collaboration server inaccurately counts connections (#8886)
* fix: Collaboration server inaccurately counts connections

* Add integration test

* docs
2025-04-06 21:55:10 +00:00
Hemachandar 2e018e74b8 Log fields that cause UniqueConstraintError in ImportsProcessor (#8887) 2025-04-06 11:10:59 -07:00
codegen-sh[bot] a11ab56117 Cleanup the old Notion importer (#8832)
* Cleanup the old Notion importer

* Fix Notion importer cleanup PR based on feedback

* Restore Notion format references for backward compatibility

* Remove Notion import fixtures

* translations

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-06 09:04:35 -07:00
codegen-sh[bot] 66e4ec32ed Fix: Handle Notion database not found errors gracefully (#8860)
* Fix: Handle Notion database not found errors gracefully

* Fix: Use Logger.warn instead of console.log in Notion import task

* Applied automatic fixes

* Touch to trigger actions

* Fix: Implement additional improvements for Notion import error handling

* Applied automatic fixes

* Change to trigger CI

* Fix TypeScript error: Add type assertion for filtered parsedPages

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
Co-authored-by: Codegen <codegen@example.com>
2025-04-06 09:04:23 -07:00
codegen-sh[bot] bde9d5fbf4 Move post-login redirect logic to AuthenticatedLayout (#8864)
* Move post-login redirect logic to AuthenticatedLayout

* Applied automatic fixes

* fix typography

* Restore Login/index.tsx

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2025-04-01 17:37:22 -07:00
Tom Moor 70bb878a8c fix: Missing transaction in save causing deadlocks (#8866) 2025-04-01 12:46:06 +00:00
Hemachandar 4237377d47 fix: Skip sequelize hooks when creating user membership in collections.update (#8849) 2025-04-01 04:43:31 -07:00
dependabot[bot] a30f6b717b chore(deps): bump the aws group with 5 updates (#8857)
Bumps the aws group with 5 updates:

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


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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 04:43:22 -07:00
dependabot[bot] 1edc23c5ae chore(deps): bump vite from 5.4.15 to 5.4.16 (#8861)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.15 to 5.4.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.16/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.16
  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-03-31 22:08:11 +00:00
dependabot[bot] ff6ec3a5b8 chore(deps): bump prosemirror-markdown from 1.13.1 to 1.13.2 (#8855)
Bumps [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown) from 1.13.1 to 1.13.2.
- [Changelog](https://github.com/ProseMirror/prosemirror-markdown/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-markdown/compare/1.13.1...1.13.2)

---
updated-dependencies:
- dependency-name: prosemirror-markdown
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 15:04:53 -07:00
dependabot[bot] 52c2729490 chore(deps-dev): bump @relative-ci/agent from 4.2.14 to 4.3.0 (#8854)
Bumps [@relative-ci/agent](https://github.com/relative-ci/agent) from 4.2.14 to 4.3.0.
- [Release notes](https://github.com/relative-ci/agent/releases)
- [Commits](https://github.com/relative-ci/agent/compare/v4.2.14...v4.3.0)

---
updated-dependencies:
- dependency-name: "@relative-ci/agent"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 14:45:36 -07:00
dependabot[bot] 82f4281a02 chore(deps): bump @tanstack/react-virtual from 3.11.3 to 3.13.6 (#8858)
Bumps [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) from 3.11.3 to 3.13.6.
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.13.6/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-03-31 14:45:13 -07:00
dependabot[bot] 12b6e30e3a chore(deps): bump prosemirror-model from 1.24.1 to 1.25.0 (#8856)
* chore(deps): bump prosemirror-model from 1.24.1 to 1.25.0

Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.24.1 to 1.25.0.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.24.1...1.25.0)

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

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

* Update Code mark to use the new `code` property

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-31 14:45:02 -07:00
Tom Moor 567ca7e3f1 fix: Table columns sometimes lost in copy paste (#8845)
closes #8841
2025-03-30 20:06:22 -07:00
codegen-sh[bot] 97c3ea7da8 Allow inline code to be bolded and italicized (#8843)
Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-03-30 14:44:21 -07:00
102 changed files with 3094 additions and 2403 deletions
+1 -1
View File
@@ -65,7 +65,7 @@
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["warn", "method"],
"lodash/import-scope": ["error", "method"],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
+6 -4
View File
@@ -11,7 +11,7 @@ env:
DATABASE_URL: postgres://postgres:password@localhost:5432/outline_test
REDIS_URL: redis://127.0.0.1:6379
URL: http://localhost:3000
NODE_OPTIONS: --max-old-space-size=8000
NODE_OPTIONS: --max-old-space-size=8192
SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B
UTILS_SECRET: 123456
SLACK_VERIFICATION_TOKEN: 123456
@@ -63,6 +63,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
server: ${{ steps.filter.outputs.server }}
app: ${{ steps.filter.outputs.app }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v2
@@ -81,7 +82,7 @@ jobs:
- 'yarn.lock'
test:
needs: build
needs: [build, changes]
if: ${{ needs.changes.outputs.app == 'true' }}
runs-on: ubuntu-latest
strategy:
@@ -143,8 +144,8 @@ jobs:
yarn test --maxWorkers=2 $TESTFILES
bundle-size:
needs: [build, types]
if: ${{ needs.changes.outputs.app == 'true' }}
needs: [build, types, changes]
if: ${{ needs.changes.outputs.app == 'true' && github.repository == 'outline/outline' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -161,3 +162,4 @@ jobs:
with:
key: ${{ secrets.RELATIVE_CI_KEY }}
token: ${{ secrets.GITHUB_TOKEN }}
webpackStatsFile: ./build/app/webpack-stats.json
+179 -19
View File
@@ -3,25 +3,32 @@ name: Docker
on:
push:
tags:
- 'v*'
- "v*"
env:
IMAGE_NAME: outlinewiki/outline
BASE_IMAGE_NAME: outlinewiki/outline-base
jobs:
build-and-push:
runs-on: ubuntu-latest
build-arm:
runs-on: ubicloud-standard-8-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
@@ -29,24 +36,177 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v5
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
push: true
tags: ${{ env.BASE_IMAGE_NAME }}:latest
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push main image
uses: docker/build-push-action@v5
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
build-amd:
runs-on: ubicloud-standard-8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker base meta
id: base_meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.BASE_IMAGE_NAME }}
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push base image
id: base_build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.base
labels: ${{ steps.base_meta.outputs.labels }}
tags: ${{ env.BASE_IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ env.IMAGE_NAME }}
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
pull: false
build-args: |
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubicloud-standard-8
needs:
- build-amd
- build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
+3 -2
View File
@@ -1,5 +1,6 @@
ARG APP_PATH=/opt/outline
FROM outlinewiki/outline-base AS base
ARG BASE_IMAGE=outlinewiki/outline-base
FROM ${BASE_IMAGE} AS base
ARG APP_PATH
WORKDIR $APP_PATH
@@ -30,7 +31,7 @@ RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
chown -R nodejs:nodejs /var/lib/outline
ENV FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
RUN mkdir -p "$FILE_STORAGE_LOCAL_ROOT_DIR" && \
+4 -1
View File
@@ -1,11 +1,14 @@
ARG APP_PATH=/opt/outline
FROM node:20-slim AS deps
FROM node:20 AS deps
ARG APP_PATH
WORKDIR $APP_PATH
COPY ./package.json ./yarn.lock ./
COPY ./patches ./patches
RUN apt-get update && apt-get install -y cmake
ENV NODE_OPTIONS="--max-old-space-size=24000"
RUN yarn install --no-optional --frozen-lockfile --network-timeout 1000000 && \
yarn cache clean
+2 -2
View File
@@ -3,7 +3,7 @@ Business Source License 1.1
Parameters
Licensor: General Outline, Inc.
Licensed Work: Outline 0.82.0
Licensed Work: Outline 0.83.0
The Licensed Work is (c) 2025 General Outline, Inc.
Additional Use Grant: You may make use of the Licensed Work, provided that
you may not use the Licensed Work for a Document
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
Licensed Work by creating teams and documents
controlled by such third parties.
Change Date: 2029-02-15
Change Date: 2029-04-11
Change License: Apache License, Version 2.0
+27 -1
View File
@@ -29,6 +29,7 @@ import {
PadlockIcon,
GlobeIcon,
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
@@ -510,6 +511,25 @@ export const copyDocumentAsMarkdown = createAction({
},
});
export const copyDocumentAsPlainText = createAction({
name: ({ t }) => t("Copy as text"),
section: ActiveDocumentSection,
keywords: "clipboard",
icon: <CaseSensitiveIcon />,
iconInContextMenu: false,
visible: ({ activeDocumentId, stores }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
perform: ({ stores, activeDocumentId, t }) => {
const document = activeDocumentId
? stores.documents.get(activeDocumentId)
: undefined;
if (document) {
copy(document.toPlainText());
toast.success(t("Text copied to clipboard"));
}
},
});
export const copyDocumentShareLink = createAction({
name: ({ t }) => t("Copy public link"),
section: ActiveDocumentSection,
@@ -555,7 +575,12 @@ export const copyDocument = createAction({
section: ActiveDocumentSection,
icon: <CopyIcon />,
keywords: "clipboard",
children: [copyDocumentLink, copyDocumentShareLink, copyDocumentAsMarkdown],
children: [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
],
});
export const duplicateDocument = createAction({
@@ -1205,6 +1230,7 @@ export const rootDocumentActions = [
copyDocumentLink,
copyDocumentShareLink,
copyDocumentAsMarkdown,
copyDocumentAsPlainText,
starDocument,
unstarDocument,
publishDocument,
+1 -2
View File
@@ -5,7 +5,6 @@ import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { changeLanguage } from "~/utils/language";
import { logoutPath } from "~/utils/routeHelpers";
import LoadingIndicator from "./LoadingIndicator";
type Props = {
@@ -33,7 +32,7 @@ const Authenticated = ({ children }: Props) => {
}
void auth.logout(true);
return <Redirect to={logoutPath()} />;
return <Redirect to="/" />;
};
export default observer(Authenticated);
+14 -1
View File
@@ -1,7 +1,13 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
import {
Switch,
Route,
useLocation,
matchPath,
Redirect,
} from "react-router-dom";
import { TeamPreference } from "@shared/types";
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
import Layout from "~/components/Layout";
@@ -10,6 +16,7 @@ import Sidebar from "~/components/Sidebar";
import SidebarRight from "~/components/Sidebar/Right";
import SettingsSidebar from "~/components/Sidebar/Settings";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -48,6 +55,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const can = usePolicy(ui.activeDocumentId);
const canCollection = usePolicy(ui.activeCollectionId);
const team = useCurrentTeam();
const [spendPostLoginPath] = usePostLoginPath();
const goToSearch = (ev: KeyboardEvent) => {
if (!ev.metaKey && !ev.ctrlKey) {
@@ -72,6 +80,11 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
return <ErrorSuspended />;
}
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
const sidebar = (
<Fade>
<Switch>
+146 -140
View File
@@ -1,4 +1,5 @@
import { m } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
@@ -23,9 +25,9 @@ const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
/** ID of the unfurl that will be shown in the hover preview. */
unfurlId: string | null;
/** Whether the preview data is being loaded. */
dataLoading: boolean;
/** A callback on close of the hover preview. */
onClose: () => void;
@@ -36,151 +38,155 @@ enum Direction {
DOWN,
}
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const HoverPreviewDesktop = observer(
({ element, unfurlId, dataLoading, onClose }: Props) => {
const { unfurls } = useStores();
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({
cardRef,
element,
isVisible,
});
const data = unfurlId ? unfurls.get(unfurlId)?.data : undefined;
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const closePreview = React.useCallback(() => {
setVisible(false);
onClose();
}, [onClose]);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}, []);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
const stopCloseTimer = React.useCallback(() => {
if (timerClose.current) {
clearTimeout(timerClose.current);
timerClose.current = undefined;
}
}
}, []);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
const startCloseTimer = React.useCallback(() => {
timerClose.current = setTimeout(closePreview, DELAY_CLOSE);
}, [closePreview]);
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
// Ensure that the preview stays open while the user is hovering over the card.
React.useEffect(() => {
const card = cardRef.current;
if (isVisible) {
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
}
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
return () => {
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
if (dataLoading) {
return <LoadingIndicator />;
stopCloseTimer();
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
if (dataLoading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
);
if (!data) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
email={data.email}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
}
function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
function HoverPreview({ element, unfurlId, dataLoading, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
@@ -190,7 +196,7 @@ function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
unfurlId={unfurlId}
dataLoading={dataLoading}
/>
);
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { IssueStatusIcon } from "@shared/components/IssueStatusIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { IssueStatusIcon } from "../Icons/IssueStatusIcon";
import Text from "../Text";
import Time from "../Time";
import {
@@ -1,9 +1,9 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { PullRequestIcon } from "@shared/components/PullRequestIcon";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import { Avatar } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { PullRequestIcon } from "../Icons/PullRequestIcon";
import Text from "../Text";
import Time from "../Time";
import {
+6 -1
View File
@@ -45,6 +45,10 @@ export const NativeInput = styled.input<{
${ellipsis()}
${undraggableOnDesktop()}
&[readOnly] {
color: ${s("textSecondary")};
}
&:disabled,
&::placeholder {
color: ${s("placeholder")};
@@ -126,13 +130,14 @@ export interface Props
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
labelHidden?: boolean;
label?: string;
flex?: boolean;
short?: boolean;
margin?: string | number;
error?: string;
rows?: number;
/** Optional component that appears inside the input before the textarea and any icon */
prefix?: React.ReactNode;
/** Optional icon that appears inside the input before the textarea */
-26
View File
@@ -1,26 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
</Flex>
);
export const Label = styled(Flex)`
font-weight: 500;
padding-bottom: 4px;
display: inline-block;
color: ${s("text")};
`;
export default observer(Labeled);
+3
View File
@@ -114,6 +114,8 @@ const Modal: React.FC<Props> = ({
<Small {...props}>
<Centered
onClick={(ev) => ev.stopPropagation()}
// maxHeight needed for proper overflow behavior in Safari
style={{ maxHeight: "65vh" }}
column
reverse
>
@@ -259,6 +261,7 @@ const Small = styled.div`
width: 75vw;
min-width: 350px;
max-width: 450px;
max-height: 65vh;
z-index: ${depths.modal};
display: flex;
justify-content: center;
+20 -6
View File
@@ -54,6 +54,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const [hasPointerMoved, setPointerMoved] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
@@ -114,6 +116,10 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerActivity = React.useCallback(() => {
if (ui.sidebarIsClosed) {
// clear the timeout when mouse exits
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
setHovering(document.hasFocus());
setPointerMoved(true);
}
@@ -122,12 +128,20 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
const handlePointerLeave = React.useCallback(
(ev) => {
if (hasPointerMoved) {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
// clear any previous timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
// add a short delay when mouse exits the sidebar before closing
hoverTimeoutRef.current = setTimeout(() => {
setHovering(
document.hasFocus() &&
ev.pageX < width &&
ev.pageY < window.innerHeight &&
ev.pageY > 0
);
}, 500);
}
},
[width, hasPointerMoved]
@@ -148,7 +148,12 @@ function InnerDocumentLink(
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
document,
isEditing
);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -270,6 +275,8 @@ function InnerDocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
@@ -285,6 +292,7 @@ function InnerDocumentLink(
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
+12 -6
View File
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
location?: Location;
strict?: boolean;
to: LocationDescriptor;
component?: React.ComponentType;
onBeforeClick?: () => void;
}
@@ -146,17 +147,22 @@ const NavLink = ({
setPreActive(undefined);
}, [currentLocation]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
},
[navigateTo]
);
return (
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onKeyDown={handleKeyDown}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document
document?: Document,
isEditing?: boolean
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -188,7 +190,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive,
canDrag: () => !!document?.isActive && !isEditing,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
+1 -71
View File
@@ -1,73 +1,3 @@
import styled, { css } from "styled-components";
import { ellipsis } from "@shared/styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
import Text from "@shared/components/Text";
export default Text;
+59 -24
View File
@@ -1,7 +1,15 @@
import { LinkIcon } from "outline-icons";
import { observer } from "mobx-react";
import { EmailIcon, LinkIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import { v4 } from "uuid";
import { EmbedDescriptor } from "@shared/editor/embeds";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import Integration from "~/models/Integration";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import { determineMentionType, isURLMentionable } from "~/utils/mention";
import SuggestionsMenu, {
Props as SuggestionsMenuProps,
} from "./SuggestionsMenu";
@@ -15,34 +23,65 @@ type Props = Omit<
embeds: EmbedDescriptor[];
};
const PasteMenu = ({ embeds, ...props }: Props) => {
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
const { t } = useTranslation();
const { integrations } = useStores();
const user = useCurrentUser({ rejectOnEmpty: false });
let mentionType: MentionType | undefined;
const url = pastedText ? new URL(pastedText) : undefined;
if (url) {
const integration = integrations.find((intg: Integration) =>
isURLMentionable({ url, integration: intg })
);
mentionType = integration
? determineMentionType({ url, integration })
: undefined;
}
const embed = React.useMemo(() => {
for (const e of embeds) {
const matches = e.matcher(props.pastedText);
const matches = e.matcher(pastedText);
if (matches) {
return e;
}
}
return;
}, [embeds, props.pastedText]);
}, [embeds, pastedText]);
const items = React.useMemo(
() => [
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
],
[embed, t]
() =>
[
{
name: "noop",
title: t("Keep as link"),
icon: <LinkIcon />,
},
{
name: "mention",
title: t("Mention"),
icon: <EmailIcon />,
visible: !!mentionType,
attrs: {
id: v4(),
type: mentionType,
label: pastedText,
href: pastedText,
modelId: v4(),
actorId: user?.id,
},
appendSpace: true,
},
{
name: "embed",
title: t("Embed"),
icon: embed?.icon,
keywords: embed?.keywords,
},
] satisfies MenuItem[],
[t, embed, mentionType, pastedText, user]
);
return (
@@ -52,9 +91,7 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
filterable={false}
renderMenuItem={(item, _index, options) => (
<SuggestionsMenuItem
onClick={() => {
props.onSelect?.(item);
}}
onClick={options.onClick}
selected={options.selected}
title={item.title}
icon={item.icon}
@@ -63,6 +100,4 @@ const PasteMenu = ({ embeds, ...props }: Props) => {
items={items}
/>
);
};
export default PasteMenu;
});
+1 -1
View File
@@ -174,12 +174,12 @@ export default function SelectionToolbar(props: Props) {
const { isTemplate, rtl, canComment, canUpdate, ...rest } = props;
const { state } = view;
const { selection } = state;
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
if ((readOnly && !canComment) || isDragging) {
return null;
}
const isDividerSelection = isNodeActive(state.schema.nodes.hr)(state);
const colIndex = getColumnIndex(state);
const rowIndex = getRowIndex(state);
const isTableSelection = colIndex !== undefined && rowIndex !== undefined;
@@ -1,5 +1,6 @@
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import { isList } from "@shared/editor/queries/isList";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
/**
@@ -18,17 +19,25 @@ export default class ClipboardTextSerializer extends Extension {
new Plugin({
key: new PluginKey("clipboardTextSerializer"),
props: {
clipboardTextSerializer: (slice) => {
clipboardTextSerializer: (slice, view) => {
const isMultiline = slice.content.childCount > 1;
// This is a cheap way to determine if the content is "complex",
// aka it has multiple marks or formatting. In which case we'll use
// markdown formatting
const hasMultipleListItems = slice.content.content
.filter((node) => node.content.content.length > 1)
.some((node) => isList(node, view.state.schema));
const hasMultipleBlockTypes =
[
...new Set(
slice.content.content
.filter((node) => node.content.content.length > 1)
.map((node) => node.type.name)
),
].length > 1;
const copyAsMarkdown =
isMultiline ||
slice.content.content.some(
(node) => node.content.content.length > 1
);
isMultiline || hasMultipleBlockTypes || hasMultipleListItems;
return copyAsMarkdown
? mdSerializer.serialize(slice.content, {
+20 -13
View File
@@ -4,9 +4,9 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import stores from "~/stores";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -16,11 +16,11 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
data: Record<string, any> | null;
unfurlId: string | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
data: null,
unfurlId: null,
dataLoading: false,
});
@@ -62,19 +62,25 @@ export default class HoverPreviews extends Extension {
);
if (url) {
const transformedUrl = url.startsWith("/")
? env.URL + url
: url;
this.state.dataLoading = true;
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
const unfurl = await stores.unfurls.fetchUnfurl({
url: transformedUrl,
documentId,
});
if (unfurl) {
this.state.activeLinkElement = element;
this.state.data = data;
} catch (err) {
this.state.unfurlId = transformedUrl;
} else {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
this.state.dataLoading = false;
}
}),
this.options.delay
@@ -101,10 +107,11 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
data={this.state.data}
unfurlId={this.state.unfurlId}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
this.state.unfurlId = null;
})}
/>
);
+21 -1
View File
@@ -24,7 +24,7 @@ import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
import { PasteMenu } from "../components/PasteMenu";
export default class PasteHandler extends Extension {
state: {
@@ -415,6 +415,21 @@ export default class PasteHandler extends Extension {
});
};
private insertMention = () => {
const { view } = this.editor;
const { state } = view;
const result = this.findPlaceholder(state, this.state.pastedText);
// Remove just the placeholder here.
// Mention node will be created by SuggestionsMenu.
if (result) {
const tr = state.tr.deleteRange(result[0], result[1]);
view.dispatch(
tr.setSelection(TextSelection.near(tr.doc.resolve(result[0])))
);
}
};
private removePlaceholder = () => {
const { view } = this.editor;
const { state } = view;
@@ -450,6 +465,11 @@ export default class PasteHandler extends Extension {
this.insertEmbed();
break;
}
case "mention": {
this.hidePasteMenu();
this.insertMention();
break;
}
default:
break;
}
+2 -18
View File
@@ -1,19 +1,3 @@
import * as React from "react";
import useIsMounted from "@shared/hooks/useIsMounted";
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
export default useIsMounted;
-10
View File
@@ -8,7 +8,6 @@ import {
GlobeIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -40,7 +39,6 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
@@ -177,14 +175,6 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update && !isCloudHosted,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
+19
View File
@@ -17,6 +17,7 @@ import {
NavigationNodeType,
NotificationEventType,
} from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { isRTL } from "@shared/utils/rtl";
import slugify from "@shared/utils/slugify";
@@ -663,6 +664,24 @@ export default class Document extends ArchivableModel implements Searchable {
return markdown;
};
/**
* Returns the plain text representation of the document derived from the ProseMirror data.
*
* @returns The plain text representation of the document as a string.
*/
toPlainText = () => {
const extensionManager = new ExtensionManager(withComments(richExtensions));
const schema = new Schema({
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
const text = ProsemirrorHelper.toPlainText(
Node.fromJSON(schema, this.data),
schema
);
return text;
};
download = (contentType: ExportContentType) =>
client.post(
`/documents.export`,
+3 -3
View File
@@ -1,8 +1,8 @@
import { observable } from "mobx";
import type {
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
type IntegrationSettings,
type IntegrationType,
} from "@shared/types";
import User from "~/models/User";
import Model from "~/models/base/Model";
+18
View File
@@ -0,0 +1,18 @@
import { observable } from "mobx";
import { UnfurlResourceType, UnfurlResponse } from "@shared/types";
import Model from "./base/Model";
class Unfurl<UnfurlType extends UnfurlResourceType> extends Model {
static modelName = "Unfurl";
@observable
type: UnfurlType;
@observable
data: UnfurlResponse[UnfurlType];
@observable
fetchedAt: string;
}
export default Unfurl;
+1 -8
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { HomeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Switch, Route, Redirect } from "react-router-dom";
import { Switch, Route } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { Action } from "~/components/Actions";
@@ -18,7 +18,6 @@ import Tab from "~/components/Tab";
import Tabs from "~/components/Tabs";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useCurrentUser from "~/hooks/useCurrentUser";
import { usePostLoginPath } from "~/hooks/useLastVisitedPath";
import { usePinnedDocuments } from "~/hooks/usePinnedDocuments";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
@@ -29,16 +28,10 @@ function Home() {
const team = useCurrentTeam();
const user = useCurrentUser();
const { t } = useTranslation();
const [spendPostLoginPath] = usePostLoginPath();
const userId = user?.id;
const { pins, count } = usePinnedDocuments("home");
const can = usePolicy(team);
const postLoginPath = spendPostLoginPath();
if (postLoginPath) {
return <Redirect to={postLoginPath} />;
}
return (
<Scene
icon={<HomeIcon />}
+2
View File
@@ -209,7 +209,9 @@ function Invite({ onSubmit }: Props) {
placeholder={`name@${predictedDomain}`}
value={invite.email}
required={index === 0}
autoComplete="off"
autoFocus
data-1p-ignore
flex
/>
<StyledInput
-140
View File
@@ -1,140 +0,0 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
gristUrl: string;
};
function SelfHosted() {
const { integrations } = useStores();
const { t } = useTranslation();
const integrationDiagrams = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const integrationGrist = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Grist,
}) as Integration<IntegrationType.Embed> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Embed,
});
}, [integrations]);
React.useEffect(() => {
reset({
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
});
}, [integrationDiagrams, integrationGrist, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.drawIoUrl) {
await integrations.save({
id: integrationDiagrams?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integrationDiagrams?.delete();
}
if (data.gristUrl) {
await integrations.save({
id: integrationGrist?.id,
type: IntegrationType.Embed,
service: IntegrationService.Grist,
settings: {
url: data.gristUrl,
},
});
} else {
await integrationGrist?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integrationDiagrams, integrationGrist, t]
);
return (
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
<Heading>{t("Self Hosted")}</Heading>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<SettingRow
label={t("Grist deployment")}
name="gristUrl"
description={t("Add your self-hosted grist installation URL here.")}
border={false}
>
<Input
placeholder="https://docs.getgrist.com/"
pattern="https?://.*"
{...register("gristUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(SelfHosted);
@@ -4,6 +4,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import {
FileOperationFormat,
FileOperationState,
@@ -13,7 +14,6 @@ import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
+155 -125
View File
@@ -1,19 +1,18 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef } from "react";
import AvatarEditor from "react-avatar-editor";
import Dropzone from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import { AttachmentValidation } from "@shared/validations";
import RootStore from "~/stores/RootStore";
import Button from "~/components/Button";
import ButtonLarge from "~/components/ButtonLarge";
import Flex from "~/components/Flex";
import LoadingIndicator from "~/components/LoadingIndicator";
import Modal from "~/components/Modal";
import withStores from "~/components/withStores";
import useStores from "~/hooks/useStores";
import { compressImage } from "~/utils/compressImage";
import { uploadFile, dataUrlToBlob } from "~/utils/files";
@@ -24,132 +23,167 @@ export type Props = {
borderRadius?: number;
};
@observer
class ImageUpload extends React.Component<RootStore & Props> {
@observable
isUploading = false;
const ImageUpload: React.FC<Props> = ({
onSuccess,
onError,
submitText,
borderRadius,
children,
}) => {
const { dialogs } = useStores();
const { t } = useTranslation();
@observable
isCropping = false;
const [isUploading, setIsUploading] = useState(false);
const [isCropping, setIsCropping] = useState(false);
@observable
zoom = 1;
const uploadImage = React.useCallback(
async (blob: Blob, file: File) => {
try {
const compressed = await compressImage(blob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
void onSuccess(attachment.url);
} catch (err) {
onError(err.message);
} finally {
setIsUploading(false);
setIsCropping(false);
dialogs.closeAllModals();
}
},
[dialogs, onSuccess, onError]
);
@observable
file: File;
const handleUpload = React.useCallback(
(blob: Blob, file: File) => {
setIsUploading(true);
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(() => uploadImage(blob, file), 0);
},
[uploadImage]
);
avatarEditorRef = React.createRef<AvatarEditor>();
const handleClose = React.useCallback(() => {
setIsUploading(false);
setIsCropping(false);
}, []);
static defaultProps = {
submitText: "Crop Image",
borderRadius: 150,
};
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = () => {
this.isUploading = true;
// allow the UI to update before converting the canvas to a Blob
// for large images this can cause the page rendering to hang.
setTimeout(this.uploadImage, 0);
};
uploadImage = async () => {
const canvas = this.avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
const compressed = await compressImage(imageBlob, {
maxHeight: 512,
maxWidth: 512,
});
const attachment = await uploadFile(compressed, {
name: this.file.name,
preset: AttachmentPreset.Avatar,
});
void this.props.onSuccess(attachment.url);
} catch (err) {
this.props.onError(err.message);
} finally {
this.isUploading = false;
this.isCropping = false;
}
};
handleClose = () => {
this.isUploading = false;
this.isCropping = false;
};
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
}
};
renderCropping() {
const { ui, submitText } = this.props;
return (
<Modal isOpen onRequestClose={this.handleClose} title="">
<Flex auto column align="center" justify="center">
{this.isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={this.avatarEditorRef}
image={this.file}
width={250}
height={250}
border={25}
borderRadius={this.props.borderRadius}
color={
ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]
} // RGBA
scale={this.zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
const onDropAccepted = React.useCallback(
async (files: File[]) => {
setIsCropping(true);
dialogs.openModal({
title: "",
content: (
<AvatarEditorDialog
file={files[0]}
onUpload={handleUpload}
isUploading={isUploading}
borderRadius={borderRadius ?? 150}
submitText={submitText || t("Crop image")}
/>
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
{this.isUploading ? "Uploading…" : submitText}
</CropButton>
</Flex>
</Modal>
);
),
onClose: handleClose,
});
},
[
t,
dialogs,
handleUpload,
handleClose,
isUploading,
borderRadius,
submitText,
]
);
const { getRootProps, getInputProps } = useDropzone({
accept: AttachmentValidation.avatarContentTypes.join(", "),
onDropAccepted,
});
if (isCropping) {
return null; // onDropAccepted would have opened a modal for cropping the image.
}
render() {
if (this.isCropping) {
return this.renderCropping();
}
return (
<div {...getRootProps()}>
<input {...getInputProps()} />
{children}
</div>
);
};
type AvatarEditorDialogProps = {
file: File;
onUpload: (blob: Blob, file: File) => void;
isUploading: boolean;
borderRadius: number;
submitText: string;
};
const AvatarEditorDialog: React.FC<AvatarEditorDialogProps> = observer(
({ file, onUpload, isUploading, borderRadius, submitText }) => {
const { ui } = useStores();
const { t } = useTranslation();
const [zoom, setZoom] = useState(1);
const avatarEditorRef = useRef<AvatarEditor>(null);
const handleUpload = React.useCallback(() => {
const canvas = avatarEditorRef.current?.getImage();
invariant(canvas, "canvas is not defined");
const blob = dataUrlToBlob(canvas.toDataURL());
onUpload(blob, file);
}, [file, onUpload]);
const handleZoom = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const target = event.target;
if (target instanceof HTMLInputElement) {
setZoom(parseFloat(target.value));
}
},
[]
);
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
{this.props.children}
</div>
)}
</Dropzone>
<Flex auto column align="center" justify="center">
{isUploading && <LoadingIndicator />}
<AvatarEditorContainer>
<AvatarEditor
ref={avatarEditorRef}
image={file}
width={250}
height={250}
border={25}
borderRadius={borderRadius}
color={ui.theme === "light" ? [255, 255, 255, 0.6] : [0, 0, 0, 0.6]} // RGBA
scale={zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={handleZoom}
/>
<br />
<ButtonLarge fullwidth onClick={handleUpload} disabled={isUploading}>
{isUploading ? `${t(`Uploading`)}` : submitText}
</ButtonLarge>
</Flex>
);
}
}
);
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
@@ -180,8 +214,4 @@ const RangeInput = styled.input`
}
`;
const CropButton = styled(Button)`
width: 300px;
`;
export default withStores(ImageUpload);
export default observer(ImageUpload);
@@ -5,12 +5,12 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import Spinner from "@shared/components/Spinner";
import { ImportState } from "@shared/types";
import Import from "~/models/Import";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
@@ -1,36 +0,0 @@
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { FileOperationFormat } from "@shared/types";
import useStores from "~/hooks/useStores";
import DropToImport from "./DropToImport";
import HelpDisclosure from "./HelpDisclosure";
function ImportNotionDialog() {
const { t } = useTranslation();
const { dialogs } = useStores();
return (
<>
<HelpDisclosure title={<Trans>Where do I find the file?</Trans>}>
<Trans
defaults="In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability."
components={{
em: <strong />,
}}
/>
</HelpDisclosure>
<DropToImport
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.Notion}
>
<>
{t(
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
)}
</>
</DropToImport>
</>
);
}
export default ImportNotionDialog;
+1 -1
View File
@@ -306,7 +306,7 @@ export default class AuthStore extends Store<Team> {
// if this logout was forced from an authenticated route then
// save the current path so we can go back there once signed in
if (savePath) {
setPostLoginPath(window.location.pathname);
setPostLoginPath(window.location.pathname + window.location.search);
}
if (tryRevokingToken) {
+3
View File
@@ -26,6 +26,7 @@ import SharesStore from "./SharesStore";
import StarsStore from "./StarsStore";
import SubscriptionsStore from "./SubscriptionsStore";
import UiStore from "./UiStore";
import UnfurlsStore from "./UnfurlsStore";
import UserMembershipsStore from "./UserMembershipsStore";
import UsersStore from "./UsersStore";
import ViewsStore from "./ViewsStore";
@@ -55,6 +56,7 @@ export default class RootStore {
searches: SearchesStore;
shares: SharesStore;
ui: UiStore;
unfurls: UnfurlsStore;
stars: StarsStore;
subscriptions: SubscriptionsStore;
users: UsersStore;
@@ -85,6 +87,7 @@ export default class RootStore {
this.registerStore(SharesStore);
this.registerStore(StarsStore);
this.registerStore(SubscriptionsStore);
this.registerStore(UnfurlsStore);
this.registerStore(UsersStore);
this.registerStore(ViewsStore);
this.registerStore(FileOperationsStore);
+85
View File
@@ -0,0 +1,85 @@
import { subMinutes } from "date-fns";
import { action } from "mobx";
import { UnfurlResourceType } from "@shared/types";
import Unfurl from "~/models/Unfurl";
import { client } from "~/utils/ApiClient";
import Logger from "~/utils/Logger";
import RootStore from "./RootStore";
import Store from "./base/Store";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class UnfurlsStore extends Store<Unfurl<any>> {
actions = []; // no default actions allowed for unfurls.
constructor(rootStore: RootStore) {
super(rootStore, Unfurl);
}
fetchUnfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
const unfurl = this.get(url);
if (unfurl) {
this.refetch({ unfurl: unfurl as Unfurl<UnfurlType>, documentId });
return unfurl;
}
return this.unfurl<UnfurlType>({ url, documentId });
};
private refetch = <UnfurlType extends UnfurlResourceType>({
unfurl,
documentId,
}: {
unfurl: Unfurl<UnfurlType>;
documentId?: string;
}) => {
const fiveMinutesAgo = subMinutes(new Date(), 5);
if (new Date(unfurl.fetchedAt) < fiveMinutesAgo) {
void this.unfurl({ url: unfurl.id, documentId });
}
};
@action
private unfurl = async <UnfurlType extends UnfurlResourceType>({
url,
documentId,
}: {
url: string;
documentId?: string;
}): Promise<Unfurl<UnfurlType> | undefined> => {
try {
this.isFetching = true;
const data = await client.post("/urls.unfurl", {
url,
documentId,
});
// unfurls can succeed with no data.
if (!data) {
return;
}
return this.add({
id: url,
type: data.type,
fetchedAt: new Date().toISOString(),
data,
} as Unfurl<UnfurlType>);
} catch (err) {
Logger.error(`Failed to unfurl url ${url}`, err);
return;
} finally {
this.isFetching = false;
}
};
}
export default UnfurlsStore;
+9 -1
View File
@@ -1,5 +1,6 @@
import commandScore from "command-score";
import invariant from "invariant";
// eslint-disable-next-line lodash/import-scope
import type { ObjectIterateeCustom } from "lodash";
import deburr from "lodash/deburr";
import filter from "lodash/filter";
@@ -98,7 +99,14 @@ export default abstract class Store<T extends Model> {
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
if (!normalized) {
return this.orderedData.slice(0, options?.maxResults);
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
}
return this.orderedData
+58
View File
@@ -0,0 +1,58 @@
import {
IntegrationService,
IntegrationSettings,
IntegrationType,
MentionType,
} from "@shared/types";
import Integration from "~/models/Integration";
export const isURLMentionable = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): boolean => {
const { hostname, pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const settings =
integration.settings as IntegrationSettings<IntegrationType.Embed>;
return (
hostname === "github.com" &&
settings.github?.installation.account.name === pathParts[1] // ensure installed org/account name matches with the provided url.
);
}
default:
return false;
}
};
export const determineMentionType = ({
url,
integration,
}: {
url: URL;
integration: Integration;
}): MentionType | undefined => {
const { pathname } = url;
const pathParts = pathname.split("/");
switch (integration.service) {
case IntegrationService.GitHub: {
const type = pathParts[3];
return type === "pull"
? MentionType.PullRequest
: type === "issues"
? MentionType.Issue
: undefined;
}
default:
return;
}
};
+5 -3
View File
@@ -49,8 +49,10 @@ export function redirectTo(url: string) {
/**
* Check if the path is a valid path for redirect after login.
*
* @param path
* @param input A path potentially including query string
* @returns boolean indicating if the path is a valid redirect
*/
export const isAllowedLoginRedirect = (path: string) =>
!["/", "/create", "/home", "/logout", "/auth/"].includes(path);
export const isAllowedLoginRedirect = (input: string) => {
const path = input.split("?")[0];
return !["/", "/create", "/home", "/logout", "/auth/"].includes(path);
};
+21 -20
View File
@@ -48,11 +48,11 @@
"> 0.25%, not dead"
],
"dependencies": {
"@aws-sdk/client-s3": "3.774.0",
"@aws-sdk/lib-storage": "3.774.0",
"@aws-sdk/s3-presigned-post": "3.774.0",
"@aws-sdk/s3-request-presigner": "3.774.0",
"@aws-sdk/signature-v4-crt": "^3.774.0",
"@aws-sdk/client-s3": "3.787.0",
"@aws-sdk/lib-storage": "3.787.0",
"@aws-sdk/s3-presigned-post": "3.787.0",
"@aws-sdk/s3-request-presigner": "3.787.0",
"@aws-sdk/signature-v4-crt": "^3.787.0",
"@babel/core": "^7.26.10",
"@babel/plugin-proposal-decorators": "^7.25.9",
"@babel/plugin-transform-class-properties": "^7.25.9",
@@ -89,7 +89,7 @@
"@sentry/node": "^7.120.3",
"@sentry/react": "^7.120.3",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.3",
"@tanstack/react-virtual": "^3.13.6",
"@tippyjs/react": "^4.2.6",
"@types/form-data": "^2.5.2",
"@types/mailparser": "^3.4.5",
@@ -141,7 +141,7 @@
"jszip": "^3.10.1",
"katex": "^0.16.21",
"kbar": "0.1.0-beta.41",
"koa": "^2.15.4",
"koa": "^2.16.1",
"koa-body": "^6.0.1",
"koa-compress": "^5.1.1",
"koa-helmet": "^6.1.0",
@@ -153,7 +153,7 @@
"koa-useragent": "^4.1.0",
"lodash": "^4.17.21",
"mailparser": "^3.7.2",
"mammoth": "^1.8.0",
"mammoth": "^1.9.0",
"markdown-it": "^13.0.2",
"markdown-it-container": "^3.0.0",
"markdown-it-emoji": "^2.0.0",
@@ -183,11 +183,11 @@
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-inputrules": "^1.5.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-model": "^1.24.0",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-markdown": "^1.13.2",
"prosemirror-model": "^1.25.0",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-transform": "1.10.0",
@@ -210,7 +210,7 @@
"react-merge-refs": "^2.1.1",
"react-portal": "^4.2.2",
"react-router-dom": "^5.3.4",
"react-virtualized-auto-sizer": "^1.0.21",
"react-virtualized-auto-sizer": "^1.0.26",
"react-waypoint": "^10.3.0",
"react-window": "^1.8.11",
"reakit": "^1.3.11",
@@ -232,7 +232,7 @@
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"socket.io-redis": "^6.1.1",
"sonner": "^1.7.1",
"sonner": "^1.7.4",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.1",
"styled-components": "^5.3.11",
@@ -248,7 +248,7 @@
"uuid": "^8.3.2",
"validator": "13.12.0",
"vaul": "^1.1.2",
"vite": "^5.4.15",
"vite": "^5.4.18",
"vite-plugin-pwa": "^0.20.3",
"winston": "^3.17.0",
"ws": "^7.5.10",
@@ -263,7 +263,7 @@
"@babel/cli": "^7.27.0",
"@babel/preset-typescript": "^7.27.0",
"@faker-js/faker": "^8.4.1",
"@relative-ci/agent": "^4.2.14",
"@relative-ci/agent": "^4.3.0",
"@testing-library/react": "^12.0.0",
"@types/addressparser": "^1.0.3",
"@types/body-scroll-lock": "^3.1.2",
@@ -296,7 +296,7 @@
"@types/markdown-it-emoji": "^2.0.4",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.17.27",
"@types/node": "20.17.30",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.17",
"@types/passport-oauth2": "^1.4.17",
@@ -362,7 +362,7 @@
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^2.0.3",
"terser": "^5.39.0",
"typescript": "^5.7.3",
"typescript": "^5.8.3",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
@@ -377,5 +377,6 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.82.0"
}
"version": "0.83.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+1 -1
View File
@@ -94,7 +94,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
/** Default user and team names metadata */
let userName = profile.username;
let teamName = "Wiki";
let teamName;
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
let teamAvatarUrl: string | undefined = undefined;
let subdomain = slugifyDomain(domain);
+2 -2
View File
@@ -206,12 +206,12 @@ export class GitHub {
);
const { data } = await client.requestResource(resource);
if (!data) {
return;
return { error: "Resource not found" };
}
return { ...data, type: resource.type };
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return;
return { error: err.message || "Unknown error" };
}
};
}
+6 -4
View File
@@ -1,6 +1,6 @@
import { JSONObject, UnfurlResourceType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { UnfurlSignature } from "@server/types";
import { UnfurlError, UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
@@ -10,7 +10,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "oembed"
): Promise<JSONObject | undefined> {
): Promise<JSONObject | UnfurlError> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
// Cloud Iframely requires /api path, while self-hosted does not.
@@ -25,7 +25,7 @@ class Iframely {
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return;
return { error: err.message || "Unknown error" };
}
}
@@ -36,7 +36,9 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return { ...data, type: UnfurlResourceType.OEmbed };
return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
? ({ error: data.error } as UnfurlError)
: { ...data, type: UnfurlResourceType.OEmbed };
};
}
+12 -1
View File
@@ -13,9 +13,11 @@ import {
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import emojiRegex from "emoji-regex";
import compact from "lodash/compact";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { isUrl } from "@shared/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
@@ -37,7 +39,16 @@ const AccessTokenResponseSchema = z.object({
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z.string().url().nullish(),
workspace_icon: z
.string()
.nullish()
.transform((val) => {
const emojiRegexp = emojiRegex();
if (val && (isUrl(val) || emojiRegexp.test(val))) {
return val;
}
return undefined;
}),
});
export class NotionClient {
@@ -1,6 +1,8 @@
import { APIResponseError, APIErrorCode } from "@notionhq/client";
import { ImportTaskInput, ImportTaskOutput } from "@shared/schema";
import { IntegrationService, ProsemirrorDoc } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Logger from "@server/logging/Logger";
import { Integration } from "@server/models";
import ImportTask from "@server/models/ImportTask";
import APIImportTask, {
@@ -39,7 +41,10 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
importTask.input.map(async (item) => this.processPage({ item, client }))
);
const taskOutput: ImportTaskOutput = parsedPages.map((parsedPage) => ({
// Filter out any null results (from pages/databases that couldn't be accessed)
const validParsedPages = parsedPages.filter(Boolean) as ParsePageOutput[];
const taskOutput: ImportTaskOutput = validParsedPages.map((parsedPage) => ({
externalId: parsedPage.externalId,
title: parsedPage.title,
emoji: parsedPage.emoji,
@@ -50,7 +55,7 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
}));
const childTasksInput: ImportTaskInput<IntegrationService.Notion> =
parsedPages.flatMap((parsedPage) =>
validParsedPages.flatMap((parsedPage) =>
parsedPage.children.map((childPage) => ({
type: childPage.type,
externalId: childPage.externalId,
@@ -88,36 +93,55 @@ export default class NotionAPIImportTask extends APIImportTask<IntegrationServic
}: {
item: ImportTaskInput<IntegrationService.Notion>[number];
client: NotionClient;
}): Promise<ParsePageOutput> {
}): Promise<ParsePageOutput | null> {
const collectionExternalId = item.collectionExternalId ?? item.externalId;
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId
);
try {
// Convert Notion database to an empty page with "pages in database" as its children.
if (item.type === PageType.Database) {
const { pages, ...databaseInfo } = await client.fetchDatabase(
item.externalId
);
return {
...databaseInfo,
externalId: item.externalId,
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
collectionExternalId,
children: pages.map((page) => ({
type: page.type,
externalId: page.id,
})),
};
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...databaseInfo,
...pageInfo,
externalId: item.externalId,
content: ProsemirrorHelper.getEmptyDocument() as ProsemirrorDoc,
content: NotionConverter.page({ children: blocks } as NotionPage),
collectionExternalId,
children: pages.map((page) => ({
type: page.type,
externalId: page.id,
})),
children: this.parseChildPages(blocks),
};
} catch (error) {
if (error instanceof APIResponseError) {
// Skip this page/database if it's not found or not accessible
if (
error.code === APIErrorCode.ObjectNotFound ||
error.code === APIErrorCode.Unauthorized
) {
Logger.warn(
`Skipping Notion ${
item.type === PageType.Database ? "database" : "page"
} ${item.externalId} - Error code: ${error.code} - ${error.message}`
);
return null;
}
}
// Re-throw other errors to be handled by the parent try/catch
throw error;
}
const { blocks, ...pageInfo } = await client.fetchPage(item.externalId);
return {
...pageInfo,
externalId: item.externalId,
content: NotionConverter.page({ children: blocks } as NotionPage),
collectionExternalId,
children: this.parseChildPages(blocks),
};
}
/**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,109 @@
import { Server } from "@hocuspocus/server";
import WebSocket from "ws";
import EDITOR_VERSION from "@shared/editor/version";
import { sleep } from "@server/utils/timers";
import { ConnectionLimitExtension } from "./ConnectionLimitExtension";
import { EditorVersionExtension } from "./EditorVersionExtension";
jest.mock("@server/env", () => ({
COLLABORATION_MAX_CLIENTS_PER_DOCUMENT: 2,
}));
describe("ConnectionLimitExtension", () => {
let server: typeof Server;
let extension: ConnectionLimitExtension;
const port = 12345;
const url = `ws://localhost:${port}`;
const documentName = "test";
beforeEach(async () => {
extension = new ConnectionLimitExtension();
server = Server.configure({
port,
extensions: [extension, new EditorVersionExtension()],
});
await server.listen();
});
afterEach(async () => {
await server.destroy();
});
const getConnections = () =>
extension.connectionsByDocument.get(documentName)?.size ?? 0;
const createWebSocket = (editorVersion = EDITOR_VERSION) =>
new Promise<WebSocket>((resolve, reject) => {
const ws = new WebSocket(
`${url}/${documentName}?editorVersion=${editorVersion}`
);
ws.on("open", () => resolve(ws));
ws.on("error", reject);
});
it("should allow connections within limit", async () => {
const ws1 = await createWebSocket();
const ws2 = await createWebSocket();
expect(ws1.readyState).toBe(WebSocket.OPEN);
expect(ws2.readyState).toBe(WebSocket.OPEN);
expect(getConnections()).toBe(2);
ws1.close();
ws2.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
it("should close connections exceeding limit", async () => {
const ws1 = await createWebSocket();
const ws2 = await createWebSocket();
const ws3 = await createWebSocket();
await sleep(250);
expect(ws3.readyState).toBe(WebSocket.CLOSED);
expect(ws2.readyState).toBe(WebSocket.OPEN);
expect(ws1.readyState).toBe(WebSocket.OPEN);
expect(getConnections()).toBe(2);
ws1.close();
ws2.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
it("should handle connections closed by other extensions", async () => {
const ws1 = await createWebSocket();
// Create a connection that will be closed by the EditorVersionExtension
const ws2 = await createWebSocket("1.0.0");
ws1.close();
ws2.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
it("should allow new connection after disconnect", async () => {
const ws1 = await createWebSocket();
const ws2 = await createWebSocket();
ws1.close();
await sleep(250);
expect(getConnections()).toBe(1);
const ws3 = await createWebSocket();
expect(ws3.readyState).toBe(WebSocket.OPEN);
expect(getConnections()).toBe(2);
ws2.close();
ws3.close();
await sleep(250);
expect(getConnections()).toBe(0);
});
});
@@ -1,8 +1,10 @@
import {
Extension,
connectedPayload,
onConnectPayload,
onDisconnectPayload,
} from "@hocuspocus/server";
import pluralize from "pluralize";
import { TooManyConnections } from "@shared/collaboration/CloseEvents";
import env from "@server/env";
import Logger from "@server/logging/Logger";
@@ -14,7 +16,7 @@ export class ConnectionLimitExtension implements Extension {
/**
* Map of documentId -> connection count
*/
connectionsByDocument: Map<string, Set<string>> = new Map();
public connectionsByDocument: Map<string, Set<string>> = new Map();
/**
* On disconnect hook
@@ -34,23 +36,30 @@ export class ConnectionLimitExtension implements Extension {
}
}
const connectionCount = connections?.size ?? 0;
Logger.debug(
"multiplayer",
`${connections?.size} connections to "${documentName}"`
`${connectionCount} ${pluralize(
"connection",
connectionCount
)} to "${documentName}"`
);
return Promise.resolve();
}
/**
* On connect hook
* onConnect hook is called when a new connection has been established.
* This is where we can check if the document has reached the maximum number of
* connections and reject the connection if it has.
*
* @param data The connect payload
* @returns Promise, resolving will allow the connection, rejecting will drop it
* @param data The onConnect payload
* @returns Promise, resolving will allow the connection, rejecting will drop.
*/
onConnect({ documentName, socketId }: withContext<onConnectPayload>) {
onConnect({ documentName }: withContext<onConnectPayload>) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
if (connections?.size >= env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT) {
Logger.info(
"multiplayer",
@@ -61,12 +70,30 @@ export class ConnectionLimitExtension implements Extension {
return Promise.reject(TooManyConnections);
}
return Promise.resolve();
}
/**
* Connected hook is called after a new connection has been established.
* We can safely update the connection count for the document.
*
* @param data The onConnect payload
* @returns Promise
*/
connected({ documentName, socketId }: withContext<connectedPayload>) {
const connections =
this.connectionsByDocument.get(documentName) || new Set();
connections.add(socketId);
this.connectionsByDocument.set(documentName, connections);
const connectionCount = connections.size ?? 0;
Logger.debug(
"multiplayer",
`${connections.size} connections to "${documentName}"`
`${connectionCount} ${pluralize(
"connection",
connectionCount
)} to "${documentName}"`
);
return Promise.resolve();
@@ -264,7 +264,6 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: existingTeam.name,
avatarUrl: existingTeam.avatarUrl,
subdomain: faker.internet.domainWord(),
},
@@ -308,7 +307,6 @@ describe("accountProvisioner", () => {
avatarUrl: faker.internet.avatar(),
},
team: {
name: team.name,
avatarUrl: team.avatarUrl,
subdomain: faker.internet.domainWord(),
},
+2 -1
View File
@@ -43,7 +43,7 @@ type Props = {
*/
teamId?: string;
/** The displayed name of the team */
name: string;
name?: string;
/** The domain name from the email of the user logging in */
domain?: string;
/** The preferred subdomain to provision for the team if not yet created */
@@ -92,6 +92,7 @@ async function accountProvisioner({
try {
result = await teamProvisioner({
name: "Wiki",
...teamParams,
authenticationProvider: authenticationProviderParams,
ip,
+2 -9
View File
@@ -3,7 +3,6 @@ import slugify from "slugify";
import { RESERVED_SUBDOMAINS } from "@shared/utils/domains";
import { traceFunction } from "@server/logging/tracing";
import { Team, Event } from "@server/models";
import { generateAvatarUrl } from "@server/utils/avatars";
type Props = {
/** The displayed name of the team */
@@ -29,20 +28,14 @@ type Props = {
async function teamCreator({
name,
domain,
subdomain,
avatarUrl,
authenticationProviders,
ip,
transaction,
}: Props): Promise<Team> {
// If the service did not provide a logo/avatar then we attempt to generate
// one via ClearBit, or fallback to colored initials in worst case scenario
if (!avatarUrl || !avatarUrl.startsWith("http")) {
avatarUrl = await generateAvatarUrl({
domain,
id: subdomain,
});
if (!avatarUrl?.startsWith("http")) {
avatarUrl = null;
}
const team = await Team.create(
+1 -1
View File
@@ -192,7 +192,7 @@ describe("userProvisioner", () => {
it("should prefer isAdmin argument over defaultUserRole", async () => {
const team = await buildTeam({
defaultUserRole: "viewer",
defaultUserRole: UserRole.Viewer,
});
const authenticationProviders = await team.$get("authenticationProviders");
const authenticationProvider = authenticationProviders[0];
@@ -86,7 +86,8 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
}
return;
},
30
30,
10000
);
}
+29
View File
@@ -11,6 +11,7 @@ import {
buildUser,
buildGuestUser,
} from "@server/test/factories";
import Collection from "./Collection";
import UserMembership from "./UserMembership";
beforeEach(() => {
@@ -78,6 +79,34 @@ describe("#delete", () => {
expect(newDocument?.deletedAt).toBeTruthy();
});
test("should soft delete archived document in an archived collection", async () => {
const user = await buildUser();
const collection = await buildCollection({
archivedAt: new Date(),
createdById: user.id,
teamId: user.teamId,
});
const document = await buildDocument({
archivedAt: new Date(),
collectionId: collection.id,
userId: user.id,
teamId: user.teamId,
});
await collection.addDocumentToStructure(document, 0);
await document.delete(user);
const [newDocument, newCollection] = await Promise.all([
Document.findByPk(document.id, {
paranoid: false,
}),
Collection.findByPk(collection.id),
]);
expect(newDocument?.lastModifiedById).toEqual(user.id);
expect(newDocument?.deletedAt).toBeTruthy();
expect(newCollection?.documentStructure).toEqual([]);
});
it("should delete draft without collection", async () => {
const user = await buildUser();
const document = await buildDraftDocument();
+13 -5
View File
@@ -1112,18 +1112,26 @@ class Document extends ArchivableModel<
// Delete a document, archived or otherwise.
delete = (user: User) =>
this.sequelize.transaction(async (transaction: Transaction) => {
if (!this.archivedAt && !this.template && this.collectionId) {
// delete any children and remove from the document structure
const collection = await Collection.findByPk(this.collectionId, {
let deleted = false;
if (!this.template && this.collectionId) {
const collection = await Collection.findByPk(this.collectionId!, {
transaction,
lock: transaction.LOCK.UPDATE,
paranoid: false,
});
await collection?.deleteDocument(this, { transaction });
} else {
if (!this.archivedAt || (this.archivedAt && collection?.archivedAt)) {
await collection?.deleteDocument(this, { transaction });
deleted = true;
}
}
if (!deleted) {
await this.destroy({
transaction,
});
deleted = true;
}
this.lastModifiedById = user.id;
@@ -251,6 +251,10 @@ describe("NotificationHelper", () => {
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
+9 -7
View File
@@ -1,4 +1,5 @@
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import {
NotificationEventType,
@@ -187,7 +188,6 @@ export default class NotificationHelper {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: {
[Op.ne]: actorId,
@@ -206,17 +206,19 @@ export default class NotificationHelper {
],
});
recipients = subscriptions.map((s) => s.user);
recipients = uniqBy(
subscriptions.map((s) => s.user),
(user) => user.id
);
}
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
const filtered = [];
for (const recipient of recipients) {
if (recipient.isSuspended) {
if (
recipient.isSuspended ||
!recipient.subscribedToEventType(notificationType)
) {
continue;
}
+7 -46
View File
@@ -1,19 +1,18 @@
import { JSDOM } from "jsdom";
import compact from "lodash/compact";
import flatten from "lodash/flatten";
import isEqual from "lodash/isEqual";
import isMatch from "lodash/isMatch";
import uniq from "lodash/uniq";
import { Node, DOMSerializer, Fragment, Mark } from "prosemirror-model";
import { Node, DOMSerializer, Fragment } from "prosemirror-model";
import * as React from "react";
import { renderToString } from "react-dom/server";
import styled, { ServerStyleSheet, ThemeProvider } from "styled-components";
import { prosemirrorToYDoc } from "y-prosemirror";
import * as Y from "yjs";
import EditorContainer from "@shared/editor/components/Styles";
import embeds from "@shared/editor/embeds";
import GlobalStyles from "@shared/styles/globals";
import light from "@shared/styles/theme";
import { MentionType, ProsemirrorData } from "@shared/types";
import { MentionType, ProsemirrorData, UnfurlResponse } from "@shared/types";
import { attachmentRedirectRegex } from "@shared/utils/ProsemirrorHelper";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isRTL } from "@shared/utils/rtl";
@@ -43,6 +42,8 @@ export type MentionAttrs = {
modelId: string;
actorId: string | undefined;
id: string;
href?: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
};
@trace()
@@ -61,47 +62,7 @@ export class ProsemirrorHelper {
);
}
let node = parser.parse(input);
// in the editor embeds are created at runtime by converting links into
// embeds where they match.Because we're converting to a CRDT structure on
// the server we need to mimic this behavior.
function urlsToEmbeds(node: Node): Node {
if (node.type.name === "paragraph") {
for (const textNode of node.content.content) {
for (const embed of embeds) {
if (
textNode.text &&
textNode.marks.some(
(m: Mark) =>
m.type.name === "link" && m.attrs.href === textNode.text
) &&
embed.matcher(textNode.text)
) {
return schema.nodes.embed.createAndFill({
href: textNode.text,
}) as Node;
}
}
}
}
if (node.content) {
const contentAsArray =
node.content instanceof Fragment
? node.content.content
: node.content;
// @ts-expect-error content
node.content = Fragment.fromArray(contentAsArray.map(urlsToEmbeds));
}
return node;
}
if (node) {
node = urlsToEmbeds(node);
}
const node = parser.parse(input);
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
}
@@ -235,7 +196,7 @@ export class ProsemirrorHelper {
node.descendants((childNode: Node) => {
if (
childNode.type.name === "mention" &&
isEqual(childNode.attrs, mention)
isMatch(childNode.attrs, mention)
) {
foundMention = true;
return false;
+2 -11
View File
@@ -2,7 +2,7 @@ import invariant from "invariant";
import filter from "lodash/filter";
import { CollectionPermission } from "@shared/types";
import { Collection, User, Team } from "@server/models";
import { allow, can } from "./cancan";
import { allow } from "./cancan";
import { and, isTeamAdmin, isTeamModel, isTeamMutable, or } from "./utils";
allow(User, "createCollection", Team, (actor, team) =>
@@ -67,15 +67,6 @@ allow(
}
);
allow(User, "export", Collection, (actor, collection) =>
and(
//
can(actor, "read", collection),
!actor.isViewer,
!actor.isGuest
)
);
allow(User, "share", Collection, (user, collection) => {
if (
!collection ||
@@ -161,7 +152,7 @@ allow(
}
);
allow(User, ["update", "archive"], Collection, (user, collection) =>
allow(User, ["update", "export", "archive"], Collection, (user, collection) =>
and(
!!collection,
!!collection?.isActive,
@@ -6,7 +6,6 @@ import ExportJSONTask from "../tasks/ExportJSONTask";
import ExportMarkdownZipTask from "../tasks/ExportMarkdownZipTask";
import ImportJSONTask from "../tasks/ImportJSONTask";
import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
import ImportNotionTask from "../tasks/ImportNotionTask";
import BaseProcessor from "./BaseProcessor";
export default class FileOperationCreatedProcessor extends BaseProcessor {
@@ -25,11 +24,6 @@ export default class FileOperationCreatedProcessor extends BaseProcessor {
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.Notion:
await ImportNotionTask.schedule({
fileOperationId: event.modelId,
});
break;
case FileOperationFormat.JSON:
await ImportJSONTask.schedule({
fileOperationId: event.modelId,
+68 -40
View File
@@ -3,7 +3,12 @@ import chunk from "lodash/chunk";
import keyBy from "lodash/keyBy";
import truncate from "lodash/truncate";
import { Fragment, Node } from "prosemirror-model";
import { CreateOptions, CreationAttributes, Transaction } from "sequelize";
import {
CreateOptions,
CreationAttributes,
Transaction,
UniqueConstraintError,
} from "sequelize";
import { v4 as uuidv4 } from "uuid";
import { randomElement } from "@shared/random";
import { ImportInput, ImportTaskInput } from "@shared/schema";
@@ -154,45 +159,59 @@ export default abstract class ImportsProcessor<
* @returns Promise that resolves when mapping and persistence is completed.
*/
private async onProcessed(importModel: Import<T>, transaction: Transaction) {
const { collections } = await this.createCollectionsAndDocuments({
importModel,
transaction,
});
// Once all collections and documents are created, update collection's document structure.
// This ensures the root documents have the whole subtree available in the structure.
for (const collection of collections) {
await Document.unscoped().findAllInBatches<Document>(
{
where: { parentDocumentId: null, collectionId: collection.id },
order: [
["createdAt", "DESC"],
["id", "ASC"],
],
transaction,
},
async (documents) => {
for (const document of documents) {
await collection.addDocumentToStructure(document, 0, {
save: false,
silent: true,
transaction,
});
}
}
);
await collection.save({ silent: true, transaction });
}
importModel.state = ImportState.Completed;
importModel.error = null; // unset any error from previous attempts.
await importModel.saveWithCtx(
createContext({
user: importModel.createdBy,
try {
const { collections } = await this.createCollectionsAndDocuments({
importModel,
transaction,
})
);
});
// Once all collections and documents are created, update collection's document structure.
// This ensures the root documents have the whole subtree available in the structure.
for (const collection of collections) {
await Document.unscoped().findAllInBatches<Document>(
{
where: { parentDocumentId: null, collectionId: collection.id },
order: [
["createdAt", "DESC"],
["id", "ASC"],
],
transaction,
},
async (documents) => {
for (const document of documents) {
await collection.addDocumentToStructure(document, 0, {
save: false,
silent: true,
transaction,
});
}
}
);
await collection.save({ silent: true, transaction });
}
importModel.state = ImportState.Completed;
importModel.error = null; // unset any error from previous attempts.
await importModel.saveWithCtx(
createContext({
user: importModel.createdBy,
transaction,
})
);
} catch (err) {
if (err instanceof UniqueConstraintError) {
Logger.error(
"ImportsProcessor persistence failed due to unique constraint error",
err,
{
fields: err.fields,
}
);
}
throw err;
}
}
/**
@@ -304,6 +323,15 @@ export default abstract class ImportsProcessor<
const output = outputMap[externalId];
// Skip this item if it has no output (likely due to an error during processing)
if (!output) {
Logger.debug(
"processor",
`Skipping item with no output: ${externalId}`
);
continue;
}
const collectionItem = importInput[externalId];
const attachments = await Attachment.findAll({
@@ -444,7 +472,7 @@ export default abstract class ImportsProcessor<
importInput: Record<string, ImportInput<any>[number]>;
actorId: string;
}): ProsemirrorDoc {
// special case when the doc content is empty
// special case when the doc content is empty.
if (!content.content.length) {
return content;
}
+1 -1
View File
@@ -170,7 +170,7 @@ export default abstract class APIImportTask<
await importTask.save({ transaction });
const associatedImport = importTask.import;
associatedImport.documentCount += importTask.input.length;
associatedImport.documentCount += taskOutputWithReplacements.length;
await associatedImport.saveWithCtx(
createContext({
user: associatedImport.createdBy,
@@ -85,16 +85,21 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
for (const recipient of recipients) {
await Notification.create({
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
});
}
await sequelize.transaction(async (transaction) => {
for (const recipient of recipients) {
await Notification.create(
{
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
},
{ transaction }
);
}
});
}
public get options() {
@@ -1,87 +0,0 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import path from "path";
import { FileOperation } from "@server/models";
import { buildFileOperation } from "@server/test/factories";
import ImportNotionTask from "./ImportNotionTask";
describe("ImportNotionTask", () => {
it("should import successfully from a Markdown export", async () => {
const fileOperation = await buildFileOperation();
Object.defineProperty(fileOperation, "handle", {
get() {
return {
path: path.resolve(
__dirname,
"..",
"..",
"test",
"fixtures",
"notion-markdown.zip"
),
cleanup: async () => {},
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
};
const task = new ImportNotionTask();
const response = await task.perform(props);
expect(response.collections.size).toEqual(2);
expect(response.documents.size).toEqual(6);
expect(response.attachments.size).toEqual(1);
// Check that the image url was replaced in the text with a redirect
const attachments = Array.from(response.attachments.values());
const documents = Array.from(response.documents.values());
expect(documents.map((d) => d.text).join("")).toContain(
attachments[0].redirectUrl
);
});
it("should import successfully from a HTML export", async () => {
const fileOperation = await buildFileOperation();
Object.defineProperty(fileOperation, "handle", {
get() {
return {
path: path.resolve(
__dirname,
"..",
"..",
"test",
"fixtures",
"notion-html.zip"
),
cleanup: async () => {},
};
},
});
jest.spyOn(FileOperation, "findByPk").mockResolvedValue(fileOperation);
const props = {
fileOperationId: fileOperation.id,
};
const task = new ImportNotionTask();
const response = await task.perform(props);
expect(response.collections.size).toEqual(2);
expect(response.documents.size).toEqual(6);
expect(response.attachments.size).toEqual(4);
// Check that the image url was replaced in the text with a redirect
const attachments = Array.from(response.attachments.values());
const attachment = attachments.find((att) =>
att.key.endsWith("Screen_Shot_2022-04-21_at_2.23.26_PM.png")
);
const documents = Array.from(response.documents.values());
expect(documents.map((d) => d.text).join("")).toContain(
attachment?.redirectUrl
);
});
});
-354
View File
@@ -1,354 +0,0 @@
import path from "path";
import fs from "fs-extra";
import compact from "lodash/compact";
import escapeRegExp from "lodash/escapeRegExp";
import mime from "mime-types";
import { v4 as uuidv4 } from "uuid";
import documentImporter from "@server/commands/documentImporter";
import { createContext } from "@server/context";
import Logger from "@server/logging/Logger";
import { FileOperation, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import ImportHelper, { FileTreeNode } from "@server/utils/ImportHelper";
import ImportTask, { StructuredImportData } from "./ImportTask";
export default class ImportNotionTask extends ImportTask {
public async parseData(
dirPath: string,
fileOperation: FileOperation
): Promise<StructuredImportData> {
const tree = await ImportHelper.toFileTree(dirPath);
if (!tree) {
throw new Error("Could not find valid content in zip file");
}
// New Notion exports have a single folder with the name of the export, we must skip this
// folder and go directly to the children.
let parsed;
if (
tree.children.length === 1 &&
tree.children[0].children.find((child) => child.title === "index")
) {
parsed = await this.parseFileTree(
fileOperation,
tree.children[0].children.filter((child) => child.title !== "index")
);
} else {
parsed = await this.parseFileTree(fileOperation, tree.children);
}
if (parsed.documents.length === 0 && parsed.collections.length === 1) {
const collection = parsed.collections[0];
const collectionId = uuidv4();
if (collection.description) {
parsed.documents.push({
title: collection.name,
icon: collection.icon,
color: collection.color,
path: "",
text: String(collection.description),
id: collection.id,
externalId: collection.externalId,
mimeType: "text/html",
collectionId,
});
}
collection.name = "Notion";
collection.icon = undefined;
collection.color = undefined;
collection.externalId = undefined;
collection.description = undefined;
collection.id = collectionId;
}
return parsed;
}
/**
* Converts the file structure from zipAsFileTree into documents,
* collections, and attachments.
*
* @param fileOperation The file operation
* @param tree An array of FileTreeNode representing root files in the zip
* @returns A StructuredImportData object
*/
private async parseFileTree(
fileOperation: FileOperation,
tree: FileTreeNode[]
): Promise<StructuredImportData> {
const user = await User.findByPk(fileOperation.userId, {
rejectOnEmpty: true,
});
const output: StructuredImportData = {
collections: [],
documents: [],
attachments: [],
};
const parseNodeChildren = async (
children: FileTreeNode[],
collectionId: string,
parentDocumentId?: string
): Promise<void> => {
await Promise.all(
children.map(async (child) => {
// Ignore the CSV's for databases upfront
if (child.path.endsWith(".csv")) {
return;
}
const id = uuidv4();
const match = child.title.match(this.NotionUUIDRegex);
const name = child.title.replace(this.NotionUUIDRegex, "");
const externalId = match ? match[0].trim() : undefined;
// If it's not a text file we're going to treat it as an attachment.
const mimeType = mime.lookup(child.name);
const isDocument =
mimeType === "text/markdown" ||
mimeType === "text/plain" ||
mimeType === "text/html";
// If it's not a document and not a folder, treat it as an attachment
if (!isDocument && mimeType) {
output.attachments.push({
id,
name: child.name,
path: child.path,
mimeType,
buffer: () => fs.readFile(child.path),
externalId,
});
return;
}
Logger.debug("task", `Processing ${name} as ${mimeType}`);
const { title, icon, text } = await sequelize.transaction(
async (transaction) =>
documentImporter({
mimeType: mimeType || "text/markdown",
fileName: name,
content:
child.children.length > 0
? ""
: await fs.readFile(child.path, "utf8"),
user,
ctx: createContext({ user, transaction }),
})
);
const existingDocumentIndex = output.documents.findIndex(
(doc) => doc.externalId === externalId
);
const existingDocument = output.documents[existingDocumentIndex];
// If there is an existing document with the same externalId that means
// we've already parsed either a folder or a file referencing the same
// document, as such we should merge.
if (existingDocument) {
if (existingDocument.text === "") {
output.documents[existingDocumentIndex].text = text;
}
await parseNodeChildren(
child.children,
collectionId,
existingDocument.id
);
} else {
output.documents.push({
id,
title,
icon,
text,
collectionId,
parentDocumentId,
path: child.path,
mimeType: mimeType || "text/markdown",
externalId,
});
await parseNodeChildren(child.children, collectionId, id);
}
})
);
};
const replaceInternalLinksAndImages = (text: string) => {
// Find if there are any images in this document
const imagesInText = this.parseImages(text);
for (const image of imagesInText) {
const name = path.basename(image.src);
const attachment = output.attachments.find(
(att) =>
att.path.endsWith(image.src) ||
encodeURI(att.path).endsWith(image.src)
);
if (!attachment) {
if (!image.src.startsWith("http")) {
Logger.info(
"task",
`Could not find referenced attachment with name ${name} and src ${image.src}`
);
}
} else {
text = text.replace(
new RegExp(escapeRegExp(image.src), "g"),
`<<${attachment.id}>>`
);
}
}
// With Notion's HTML import, images sometimes come wrapped in anchor tags
// This isn't supported in Outline's editor, so we need to strip them.
text = text.replace(/\[!\[([^[]+)]/g, "![]");
// Find if there are any links in this document pointing to other documents
const internalLinksInText = this.parseInternalLinks(text);
// For each link update to the standardized format of <<documentId>>
// instead of a relative or absolute URL within the original zip file.
for (const link of internalLinksInText) {
const doc = output.documents.find(
(doc) => doc.externalId === link.externalId
);
if (!doc) {
Logger.info(
"task",
`Could not find referenced document with externalId ${link.externalId}`
);
} else {
text = text.replace(link.href, `<<${doc.id}>>`);
}
}
return text;
};
// All nodes in the root level should become collections
for (const node of tree) {
const match = node.title.match(this.NotionUUIDRegex);
const name = node.title.replace(this.NotionUUIDRegex, "");
const externalId = match ? match[0].trim() : undefined;
const mimeType = mime.lookup(node.name);
const existingCollectionIndex = output.collections.findIndex(
(collection) => collection.externalId === externalId
);
const existingCollection = output.collections[existingCollectionIndex];
const collectionId = existingCollection?.id || uuidv4();
let description;
// Root level docs become the descriptions of collections
if (
mimeType === "text/markdown" ||
mimeType === "text/plain" ||
mimeType === "text/html"
) {
const { text } = await sequelize.transaction(async (transaction) =>
documentImporter({
mimeType,
fileName: name,
content: await fs.readFile(node.path, "utf8"),
user,
ctx: createContext({ user, transaction }),
})
);
description = text;
} else if (node.children.length > 0) {
await parseNodeChildren(node.children, collectionId);
} else {
Logger.debug("task", `Unhandled file in zip: ${node.path}`, {
fileOperationId: fileOperation.id,
});
continue;
}
if (existingCollectionIndex !== -1) {
if (description) {
output.collections[existingCollectionIndex].description = description;
}
} else {
output.collections.push({
id: collectionId,
name,
description,
externalId,
});
}
}
for (const document of output.documents) {
document.text = replaceInternalLinksAndImages(document.text);
}
for (const collection of output.collections) {
if (typeof collection.description === "string") {
collection.description = replaceInternalLinksAndImages(
collection.description
);
}
}
return output;
}
/**
* Extracts internal links from a markdown document, taking into account the
* externalId of the document, which is part of the link title.
*
* @param text The markdown text to parse
* @returns An array of internal links
*/
private parseInternalLinks(
text: string
): { title: string; href: string; externalId: string }[] {
return compact(
[...text.matchAll(this.NotionLinkRegex)].map((match) => ({
title: match[1],
href: match[2],
externalId: match[3],
}))
);
}
/**
* Extracts images from the markdown document
*
* @param text The markdown text to parse
* @returns An array of internal links
*/
private parseImages(text: string): { alt: string; src: string }[] {
return compact(
[...text.matchAll(this.ImageRegex)].map((match) => ({
alt: match[1],
src: match[2],
}))
);
}
/**
* Regex to find markdown images of all types
*/
private ImageRegex =
/!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<title>[^\][”]+)?”?\)/g;
/**
* Regex to find markdown links containing ID's that look like UUID's with the
* "-"'s removed, Notion's externalId format.
*/
private NotionLinkRegex = /\[([^[]+)]\((.*?([0-9a-fA-F]{32})\..*?)\)/g;
/**
* Regex to find Notion document UUID's in the title of a document.
*/
private NotionUUIDRegex =
/\s([0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
}
@@ -597,6 +597,7 @@ router.post(
createdById: user.id,
},
transaction,
hooks: false,
});
}
@@ -2,6 +2,7 @@ import queryString from "query-string";
import { v4 as uuidv4 } from "uuid";
import { randomElement } from "@shared/random";
import { NotificationEventType } from "@shared/types";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import {
buildCollection,
buildDocument,
@@ -697,3 +698,40 @@ describe("#notifications.update_all", () => {
expect(body.data.total).toBe(2);
});
});
describe("#notifications.unsubscribe", () => {
it("should allow unsubscribe with valid token", async () => {
const user = await buildUser();
const token = NotificationSettingsHelper.unsubscribeToken(
user.id,
NotificationEventType.UpdateDocument
);
const res = await server.get(
`/api/notifications.unsubscribe?userId=${user.id}&token=${token}&eventType=documents.update&follow=true`,
{
redirect: "manual",
}
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain(
"/settings/notifications?success"
);
const events = (await user.reload()).notificationSettings;
expect(events).not.toContain("documents.update");
});
it("should not allow unsubscribe with invalid token", async () => {
const user = await buildUser();
const res = await server.get(
`/api/notifications.unsubscribe?userId=${user.id}&token=invalid-token&eventType=documents.update&follow=true`,
{
redirect: "manual",
}
);
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("?notice=invalid-auth");
});
});
@@ -53,7 +53,7 @@ const handleUnsubscribe = async (
});
user.setNotificationEventType(eventType, false);
await user.save();
await user.save({ transaction });
ctx.redirect(`${user.team.url}/settings/notifications?success`);
};
+1 -1
View File
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
jest
.spyOn(Iframely, "requestResource")
.mockImplementation(() => Promise.resolve(undefined));
.mockImplementation(() => Promise.resolve({}));
const server = getTestServer();
+4 -3
View File
@@ -98,11 +98,12 @@ router.post(
}
for (const plugin of plugins) {
const data = await plugin.value.unfurl(url, actor);
if (data) {
if ("error" in data) {
const unfurl = await plugin.value.unfurl(url, actor);
if (unfurl) {
if (unfurl.error) {
return (ctx.response.status = 204);
} else {
const data = unfurl as Unfurl;
await CacheHelper.setData(
CacheHelper.getUnfurlKey(actor.teamId, url),
data,
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -578,10 +578,12 @@ export type CollectionJSONExport = {
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
export type UnfurlError = { error: string };
export type UnfurlSignature = (
url: string,
actor?: User
) => Promise<Unfurl | void>;
) => Promise<Unfurl | UnfurlError | undefined>;
export type UninstallSignature = (integration: Integration) => Promise<void>;
+5 -5
View File
@@ -18,11 +18,14 @@ export class CacheHelper {
* @param key Cache key
* @param callback Callback to get the data if not found in cache
* @param expiry Cache data expiry in seconds
* @param lockTimeout Lock timeout in milliseconds
* @returns The data from cache or the result of the callback
*/
public static async getDataOrSet<T>(
key: string,
callback: () => Promise<T | undefined>,
expiry?: number
expiry: number,
lockTimeout: number = MutexLock.defaultLockTimeout
): Promise<T | undefined> {
let cache = await this.getData<T>(key);
@@ -35,10 +38,7 @@ export class CacheHelper {
const lockKey = `lock:${key}`;
try {
try {
lock = await MutexLock.lock.acquire(
[lockKey],
MutexLock.defaultLockTimeout
);
lock = await MutexLock.lock.acquire([lockKey], lockTimeout);
} catch (err) {
Logger.error(`Could not acquire lock for ${key}`, err);
}
-9
View File
@@ -1,9 +0,0 @@
import { generateAvatarUrl } from "./avatars";
it("should return clearbit url if available", async () => {
const url = await generateAvatarUrl({
id: "google",
domain: "google.com",
});
expect(url).toBe("https://logo.clearbit.com/google.com");
});
-28
View File
@@ -1,28 +0,0 @@
import crypto from "crypto";
import fetch from "./fetch";
export async function generateAvatarUrl({
id,
domain,
}: {
id: string;
domain?: string;
}) {
// attempt to get logo from Clearbit API. If one doesn't exist then
// fall back to using tiley to generate a placeholder logo
const hash = crypto.createHash("sha256");
hash.update(id);
let cbResponse, cbUrl;
if (domain) {
cbUrl = `https://logo.clearbit.com/${domain}`;
try {
cbResponse = await fetch(cbUrl);
} catch (err) {
// okay
}
}
return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : null;
}
+87
View File
@@ -0,0 +1,87 @@
import {
buildCollection,
buildDocument,
buildStar,
buildTeam,
buildUser,
} from "@server/test/factories";
import { collectionIndexing, starIndexing } from "./indexing";
describe("collectionIndexing", () => {
it("should generate index for collections without index", async () => {
const team = await buildTeam();
const collections = await Promise.all([
buildCollection({
teamId: team.id,
}),
buildCollection({
teamId: team.id,
}),
]);
// Set index to null to simulate no index
collections[0].index = null;
collections[1].index = null;
await collections[0].save({ hooks: false });
await collections[1].save({ hooks: false });
const result = await collectionIndexing(team.id, {});
expect(Object.keys(result).length).toBe(2);
expect(result[collections[0].id]).toBeTruthy();
expect(result[collections[1].id]).toBeTruthy();
});
it("should maintain existing indices", async () => {
const team = await buildTeam();
const collection = await buildCollection({
teamId: team.id,
index: "a1",
});
const result = await collectionIndexing(team.id, {});
expect(result[collection.id]).toBe("a1");
});
});
describe("starIndexing", () => {
it("should generate index for stars without index", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument();
const stars = await Promise.all([
buildStar({
userId: user.id,
documentId: document.id,
}),
buildStar({
userId: user.id,
documentId: document.id,
}),
]);
// Set index to null to simulate no index
stars[0].index = null;
stars[1].index = null;
await stars[0].save({ hooks: false });
await stars[1].save({ hooks: false });
const result = await starIndexing(user.id);
expect(Object.keys(result).length).toBe(2);
expect(result[stars[0].id]).toBeTruthy();
expect(result[stars[1].id]).toBeTruthy();
});
it("should maintain existing indices", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const document = await buildDocument();
const star = await buildStar({
userId: user.id,
documentId: document.id,
index: "a1",
});
const result = await starIndexing(user.id);
expect(result[star.id]).toBe("a1");
});
});
+5 -5
View File
@@ -10,10 +10,8 @@ export async function collectionIndexing(
const collections = await Collection.findAll({
where: {
teamId,
// no point in maintaining index of deleted collections.
deletedAt: null,
},
attributes: ["id", "index", "name"],
attributes: ["id", "index", "name", "teamId"],
transaction,
});
@@ -26,7 +24,9 @@ export async function collectionIndexing(
for (const collection of sortable) {
if (collection.index === null) {
collection.index = fractionalIndex(previousIndex, null);
promises.push(collection.save({ fields: ["index"], transaction })); // save only index to prevent overwriting other unfetched fields.
promises.push(
collection.save({ fields: ["index"], silent: true, transaction })
);
}
previousIndex = collection.index;
@@ -67,7 +67,7 @@ export async function starIndexing(userId: string) {
for (const star of sortable) {
if (star.index === null) {
star.index = fractionalIndex(previousIndex, null);
promises.push(star.save());
promises.push(star.save({ silent: true }));
}
previousIndex = star.index;
+73
View File
@@ -0,0 +1,73 @@
import styled, { css } from "styled-components";
import { ellipsis } from "../styles";
type Props = {
/** The type of text to render */
type?: "secondary" | "tertiary" | "danger";
/** The size of the text */
size?: "xlarge" | "large" | "medium" | "small" | "xsmall";
/** The direction of the text (defaults to ltr) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the text should be selectable (defaults to false) */
selectable?: boolean;
/** The font weight of the text */
weight?: "xbold" | "bold" | "normal";
/** Whether the text should be italic */
italic?: boolean;
/** Whether the text should be truncated with an ellipsis */
ellipsis?: boolean;
/** Whether the text should be monospaced */
monospace?: boolean;
};
/**
* Use this component for all interface text that should not be selectable
* by the user, this is the majority of UI text explainers, notes, headings.
*/
const Text = styled.span<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "inherit")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
: props.type === "tertiary"
? props.theme.textTertiary
: props.type === "danger"
? props.theme.brand.red
: props.theme.text};
font-size: ${(props) =>
props.size === "xlarge"
? "26px"
: props.size === "large"
? "18px"
: props.size === "medium"
? "16px"
: props.size === "small"
? "14px"
: props.size === "xsmall"
? "13px"
: "inherit"};
${(props) =>
props.weight &&
css`
font-weight: ${props.weight === "xbold"
? 600
: props.weight === "bold"
? 500
: props.weight === "normal"
? 400
: "inherit"};
`}
font-style: ${(props) => (props.italic ? "italic" : "normal")};
font-family: ${(props) =>
props.monospace ? props.theme.fontFamilyMono : "inherit"};
white-space: normal;
user-select: ${(props) => (props.selectable ? "text" : "none")};
${(props) => props.ellipsis && ellipsis()}
`;
export default Text;
+220 -8
View File
@@ -1,17 +1,49 @@
import { observer } from "mobx-react";
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
import {
DocumentIcon,
EmailIcon,
CollectionIcon,
WarningIcon,
} from "outline-icons";
import { Node } from "prosemirror-model";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import Flex from "../../components/Flex";
import Icon from "../../components/Icon";
import { IssueStatusIcon } from "../../components/IssueStatusIcon";
import { PullRequestIcon } from "../../components/PullRequestIcon";
import Spinner from "../../components/Spinner";
import Text from "../../components/Text";
import useIsMounted from "../../hooks/useIsMounted";
import useStores from "../../hooks/useStores";
import theme from "../../styles/theme";
import type {
JSONValue,
UnfurlResourceType,
UnfurlResponse,
} from "../../types";
import { cn } from "../styles/utils";
import { ComponentProps } from "../types";
const getAttributesFromNode = (node: Node) => {
const spec = node.type.spec.toDOM?.(node) as any as Record<string, string>[];
const { class: className, ...attrs } = spec[1];
return { className, ...attrs };
type Attrs = {
className: string;
unfurl?: UnfurlResponse[keyof UnfurlResponse];
} & Record<string, JSONValue>;
const getAttributesFromNode = (node: Node): Attrs => {
const spec = node.type.spec.toDOM?.(node) as any as Record<
string,
JSONValue
>[];
const { class: className, "data-unfurl": unfurl, ...attrs } = spec[1];
return {
className: className as Attrs["className"],
unfurl: unfurl ? (JSON.parse(unfurl as any) as Attrs["unfurl"]) : undefined,
...attrs,
};
};
export const MentionUser = observer(function MentionUser_(
@@ -20,7 +52,7 @@ export const MentionUser = observer(function MentionUser_(
const { isSelected, node } = props;
const { users } = useStores();
const user = users.get(node.attrs.modelId);
const { className, ...attrs } = getAttributesFromNode(node);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
return (
<span
@@ -42,7 +74,7 @@ export const MentionDocument = observer(function MentionDocument_(
const { documents } = useStores();
const doc = documents.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
@@ -75,7 +107,7 @@ export const MentionCollection = observer(function MentionCollection_(
const { collections } = useStores();
const collection = collections.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
@@ -100,3 +132,183 @@ export const MentionCollection = observer(function MentionCollection_(
</Link>
);
});
type IssuePrProps = ComponentProps & {
onChangeUnfurl: (
unfurl:
| UnfurlResponse[UnfurlResourceType.Issue]
| UnfurlResponse[UnfurlResourceType.PR]
) => void;
};
export const MentionIssue = observer((props: IssuePrProps) => {
const { unfurls } = useStores();
const isMounted = useIsMounted();
const [loaded, setLoaded] = React.useState(false);
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
const { isSelected, node } = props;
const {
className,
unfurl: unfurlAttr,
...attrs
} = getAttributesFromNode(node);
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
React.useEffect(() => {
const fetchIssue = async () => {
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
if (!isMounted()) {
return;
}
if (unfurlModel) {
onChangeUnfurl({
...unfurlModel.data,
description: null,
} satisfies UnfurlResponse[UnfurlResourceType.Issue]);
}
setLoaded(true);
};
void fetchIssue();
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
if (!unfurl) {
return !loaded ? (
<MentionLoading className={className} />
) : (
<MentionError className={className} />
);
}
const issue = unfurl as UnfurlResponse[UnfurlResourceType.Issue];
return (
<a
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
href={attrs.href as string}
target="_blank"
rel="noopener noreferrer nofollow"
>
<Flex align="center" gap={6}>
<IssueStatusIcon
size={14}
status={issue.state.name}
color={issue.state.color}
/>
<Flex align="center" gap={4}>
<Text>{issue.title}</Text>
<Text type="tertiary">{issue.id}</Text>
</Flex>
</Flex>
</a>
);
});
export const MentionPullRequest = observer((props: IssuePrProps) => {
const { unfurls } = useStores();
const isMounted = useIsMounted();
const [loaded, setLoaded] = React.useState(false);
const onChangeUnfurl = React.useRef(props.onChangeUnfurl).current; // stable reference to callback function.
const { isSelected, node } = props;
const {
className,
unfurl: unfurlAttr,
...attrs
} = getAttributesFromNode(node);
const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr;
React.useEffect(() => {
const fetchPR = async () => {
const unfurlModel = await unfurls.fetchUnfurl({ url: attrs.href });
if (!isMounted()) {
return;
}
if (unfurlModel) {
onChangeUnfurl({
...unfurlModel.data,
description: null,
} satisfies UnfurlResponse[UnfurlResourceType.PR]);
}
setLoaded(true);
};
void fetchPR();
}, [unfurls, attrs.href, isMounted, onChangeUnfurl]);
const sharedProps = {
className: cn(className, {
"ProseMirror-selectednode": isSelected,
}),
};
if (!unfurl) {
return !loaded ? (
<MentionLoading {...sharedProps} />
) : (
<MentionError {...sharedProps} />
);
}
const pullRequest = unfurl as UnfurlResponse[UnfurlResourceType.PR];
return (
<a
{...attrs}
{...sharedProps}
href={attrs.href as string}
target="_blank"
rel="noopener noreferrer nofollow"
>
<Flex align="center" gap={6}>
<PullRequestIcon
size={14}
status={pullRequest.state.name}
color={pullRequest.state.color}
/>
<Flex align="center" gap={4}>
<Text>{pullRequest.title}</Text>
<Text type="tertiary">{pullRequest.id}</Text>
</Flex>
</Flex>
</a>
);
});
const MentionLoading = ({ className }: { className: string }) => {
const { t } = useTranslation();
return (
<span className={className}>
<Spinner />
<Text type="tertiary">{`${t("Loading")}`}</Text>
</span>
);
};
const MentionError = ({ className }: { className: string }) => {
const { t } = useTranslation();
return (
<span className={className}>
<StyledWarningIcon size={20} color={theme.danger} />
<Text type="secondary">{`${t("Error loading data")}`}</Text>
</span>
);
};
const StyledWarningIcon = styled(WarningIcon)`
margin: 0 -2px;
`;
+8
View File
@@ -707,6 +707,7 @@ img.ProseMirror-separator {
resize: none;
user-select: text;
margin: 0 auto !important;
max-width: 100vw;
}
.ProseMirror[contenteditable="false"] {
@@ -1335,6 +1336,13 @@ mark {
position: relative;
}
.code-block[data-language=none],
.code-block[data-language=markdown] {
pre code {
color: ${props.theme.text};
}
}
.code-block[data-language=mermaidjs] {
margin: 0.75em 0;
+2 -1
View File
@@ -1,3 +1,4 @@
import { BrowserIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { Primitive } from "utility-types";
@@ -665,7 +666,7 @@ const embeds: EmbedDescriptor[] = [
title: "Embed",
keywords: "iframe webpage",
placeholder: "Paste a URL to embed",
icon: <Img src="/images/embed.png" alt="Embed" />,
icon: <BrowserIcon />,
defaultHidden: false,
matchOnInput: false,
regexMatch: [new RegExp("^https?://(.*)$")],
@@ -4,7 +4,7 @@ import { Node } from "prosemirror-model";
import { Plugin, PluginKey, Transaction } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import refractor from "refractor/core";
import { getPrismLangForLanguage } from "../lib/code";
import { getLoaderForLanguage, getRefractorLangForLanguage } from "../lib/code";
import { isRemoteTransaction } from "../lib/multiplayer";
import { findBlockNodes } from "../queries/findChildren";
@@ -14,6 +14,44 @@ type ParsedNode = {
};
const cache: Record<number, { node: Node; decorations: Decoration[] }> = {};
const languagesToImport = new Set<string>();
const languagePromises: Record<
string,
Promise<string | undefined> | undefined
> = {};
async function loadLanguage(language: string) {
if (!language || refractor.registered(language)) {
return;
}
if (languagePromises[language]) {
return languagePromises[language];
}
const loader = getLoaderForLanguage(language);
if (!loader) {
return;
}
languagePromises[language] = loader()
.then((syntax) => {
refractor.register(syntax);
return language;
})
.catch((err) => {
// It will retry loading the language on the next render
// eslint-disable-next-line no-console
console.error(
`Failed to load language ${language} for code highlighting`,
err
);
delete languagePromises[language]; // Remove failed promise from cache
return undefined;
});
return languagePromises[language];
}
function getDecorations({
doc,
@@ -57,12 +95,8 @@ function getDecorations({
blocks.forEach((block) => {
let startPos = block.pos + 1;
const language = getPrismLangForLanguage(block.node.attrs.language);
if (!language || !refractor.registered(language)) {
return;
}
const language = block.node.attrs.language;
const lang = getRefractorLangForLanguage(language);
const lineDecorations = [];
if (!cache[block.pos] || !cache[block.pos].node.eq(block.node)) {
@@ -91,35 +125,48 @@ function getDecorations({
);
}
const nodes = refractor.highlight(block.node.textContent, language);
const newDecorations = parseNodes(nodes)
.map((node: ParsedNode) => {
const from = startPos;
const to = from + node.text.length;
startPos = to;
return {
...node,
from,
to,
};
})
.filter((node) => node.classes && node.classes.length)
.map((node) =>
Decoration.inline(node.from, node.to, {
class: node.classes.join(" "),
})
)
.concat(lineDecorations);
cache[block.pos] = {
node: block.node,
decorations: newDecorations,
decorations: lineDecorations,
};
if (!lang) {
// do nothing
} else if (refractor.registered(lang)) {
languagesToImport.delete(language);
const nodes = refractor.highlight(block.node.textContent, lang);
const newDecorations = parseNodes(nodes)
.map((node: ParsedNode) => {
const from = startPos;
const to = from + node.text.length;
startPos = to;
return {
...node,
from,
to,
};
})
.filter((node) => node.classes && node.classes.length)
.map((node) =>
Decoration.inline(node.from, node.to, {
class: node.classes.join(" "),
})
)
.concat(lineDecorations);
cache[block.pos] = {
node: block.node,
decorations: newDecorations,
};
} else {
languagesToImport.add(language);
}
}
cache[block.pos].decorations.forEach((decoration) => {
cache[block.pos]?.decorations.forEach((decoration) => {
decorations.push(decoration);
});
});
@@ -133,7 +180,7 @@ function getDecorations({
return DecorationSet.create(doc, decorations);
}
export default function Prism({
export function CodeHighlighting({
name,
lineNumbers,
}: {
@@ -145,7 +192,7 @@ export default function Prism({
let highlighted = false;
return new Plugin({
key: new PluginKey("prism"),
key: new PluginKey("codeHighlighting"),
state: {
init: (_, { doc }) => DecorationSet.create(doc, []),
apply: (transaction: Transaction, decorationSet, oldState, state) => {
@@ -156,11 +203,13 @@ export default function Prism({
// @ts-expect-error accessing private field.
const isPaste = transaction.meta?.paste;
const langLoaded = transaction.getMeta("codeHighlighting")?.langLoaded;
if (
!highlighted ||
codeBlockChanged ||
isPaste ||
langLoaded ||
isRemoteTransaction(transaction)
) {
highlighted = true;
@@ -174,15 +223,35 @@ export default function Prism({
if (!highlighted) {
// we don't highlight code blocks on the first render as part of mounting
// as it's expensive (relative to the rest of the document). Instead let
// it render un-highlighted and then trigger a defered render of Prism
// it render un-highlighted and then trigger a defered render of highlighting
// by updating the plugins metadata
setTimeout(() => {
requestAnimationFrame(() => {
if (!view.isDestroyed) {
view.dispatch(view.state.tr.setMeta("prism", { loaded: true }));
view.dispatch(
view.state.tr.setMeta("codeHighlighting", { loaded: true })
);
}
}, 10);
});
}
return {};
return {
update: () => {
if (!languagesToImport.size) {
return;
}
void Promise.all([...languagesToImport].map(loadLanguage)).then(
(language) => {
if (language && languagesToImport.size) {
view.dispatch(
view.state.tr.setMeta("codeHighlighting", {
langLoaded: language,
})
);
}
}
);
},
};
},
props: {
decorations(state) {
+8 -8
View File
@@ -1,12 +1,12 @@
import { getPrismLangForLanguage, getLabelForLanguage } from "./code";
import { getRefractorLangForLanguage, getLabelForLanguage } from "./code";
describe("getPrismLangForLanguage", () => {
it("should return the correct Prism language identifier for a given language", () => {
expect(getPrismLangForLanguage("javascript")).toBe("javascript");
expect(getPrismLangForLanguage("mermaidjs")).toBe("mermaid");
expect(getPrismLangForLanguage("xml")).toBe("markup");
expect(getPrismLangForLanguage("unknown")).toBeUndefined();
expect(getPrismLangForLanguage("")).toBeUndefined();
describe("getRefractorLangForLanguage", () => {
it("should return the correct lang identifier for a given language", () => {
expect(getRefractorLangForLanguage("javascript")).toBe("javascript");
expect(getRefractorLangForLanguage("mermaidjs")).toBe("mermaid");
expect(getRefractorLangForLanguage("xml")).toBe("markup");
expect(getRefractorLangForLanguage("unknown")).toBeUndefined();
expect(getRefractorLangForLanguage("")).toBeUndefined();
});
});
+317 -62
View File
@@ -1,69 +1,313 @@
import type { RefractorSyntax } from "refractor";
import Storage from "../../utils/Storage";
const RecentStorageKey = "rme-code-language";
const RecentlyUsedStorageKey = "rme-code-language";
const StorageKey = "frequent-code-languages";
const frequentLanguagesToGet = 5;
const frequentLanguagesToTrack = 10;
type CodeLanguage = {
lang: string;
label: string;
loader?: () => Promise<RefractorSyntax>;
};
/**
* List of supported code languages.
*
* Object key is the language identifier used in the editor, lang is the
* language identifier used by Prism. Note mismatches such as `markup` and
* language identifier used by Refractor. Note mismatches such as `markup` and
* `mermaid`.
*/
export const codeLanguages = {
export const codeLanguages: Record<string, CodeLanguage> = {
none: { lang: "", label: "Plain text" },
bash: { lang: "bash", label: "Bash" },
clike: { lang: "clike", label: "C" },
cpp: { lang: "cpp", label: "C++" },
csharp: { lang: "csharp", label: "C#" },
css: { lang: "css", label: "CSS" },
docker: { lang: "docker", label: "Docker" },
elixir: { lang: "elixir", label: "Elixir" },
erlang: { lang: "erlang", label: "Erlang" },
go: { lang: "go", label: "Go" },
graphql: { lang: "graphql", label: "GraphQL" },
groovy: { lang: "groovy", label: "Groovy" },
haskell: { lang: "haskell", label: "Haskell" },
hcl: { lang: "hcl", label: "HCL" },
markup: { lang: "markup", label: "HTML" },
ini: { lang: "ini", label: "INI" },
java: { lang: "java", label: "Java" },
javascript: { lang: "javascript", label: "JavaScript" },
json: { lang: "json", label: "JSON" },
jsx: { lang: "jsx", label: "JSX" },
kotlin: { lang: "kotlin", label: "Kotlin" },
lisp: { lang: "lisp", label: "Lisp" },
lua: { lang: "lua", label: "Lua" },
mermaidjs: { lang: "mermaid", label: "Mermaid Diagram" },
nginx: { lang: "nginx", label: "Nginx" },
nix: { lang: "nix", label: "Nix" },
objectivec: { lang: "objectivec", label: "Objective-C" },
ocaml: { lang: "ocaml", label: "OCaml" },
perl: { lang: "perl", label: "Perl" },
php: { lang: "php", label: "PHP" },
powershell: { lang: "powershell", label: "Powershell" },
protobuf: { lang: "protobuf", label: "Protobuf" },
python: { lang: "python", label: "Python" },
r: { lang: "r", label: "R" },
ruby: { lang: "ruby", label: "Ruby" },
rust: { lang: "rust", label: "Rust" },
scala: { lang: "scala", label: "Scala" },
sass: { lang: "sass", label: "Sass" },
scss: { lang: "scss", label: "SCSS" },
sql: { lang: "sql", label: "SQL" },
solidity: { lang: "solidity", label: "Solidity" },
swift: { lang: "swift", label: "Swift" },
toml: { lang: "toml", label: "TOML" },
tsx: { lang: "tsx", label: "TSX" },
typescript: { lang: "typescript", label: "TypeScript" },
vb: { lang: "vb", label: "Visual Basic" },
verilog: { lang: "verilog", label: "Verilog" },
vhdl: { lang: "vhdl", label: "VHDL" },
yaml: { lang: "yaml", label: "YAML" },
xml: { lang: "markup", label: "XML" },
zig: { lang: "zig", label: "Zig" },
bash: {
lang: "bash",
label: "Bash",
loader: () => import("refractor/lang/bash").then((m) => m.default),
},
clike: {
lang: "clike",
label: "C",
loader: () => import("refractor/lang/clike").then((m) => m.default),
},
cpp: {
lang: "cpp",
label: "C++",
loader: () => import("refractor/lang/cpp").then((m) => m.default),
},
csharp: {
lang: "csharp",
label: "C#",
loader: () => import("refractor/lang/csharp").then((m) => m.default),
},
css: {
lang: "css",
label: "CSS",
loader: () => import("refractor/lang/css").then((m) => m.default),
},
csv: {
lang: "csv",
label: "CSV",
loader: () => import("refractor/lang/csv").then((m) => m.default),
},
docker: {
lang: "docker",
label: "Docker",
loader: () => import("refractor/lang/docker").then((m) => m.default),
},
elixir: {
lang: "elixir",
label: "Elixir",
loader: () => import("refractor/lang/elixir").then((m) => m.default),
},
erb: {
lang: "erb",
label: "ERB",
loader: () => import("refractor/lang/erb").then((m) => m.default),
},
erlang: {
lang: "erlang",
label: "Erlang",
loader: () => import("refractor/lang/erlang").then((m) => m.default),
},
go: {
lang: "go",
label: "Go",
loader: () => import("refractor/lang/go").then((m) => m.default),
},
graphql: {
lang: "graphql",
label: "GraphQL",
loader: () => import("refractor/lang/graphql").then((m) => m.default),
},
groovy: {
lang: "groovy",
label: "Groovy",
loader: () => import("refractor/lang/groovy").then((m) => m.default),
},
haskell: {
lang: "haskell",
label: "Haskell",
loader: () => import("refractor/lang/haskell").then((m) => m.default),
},
hcl: {
lang: "hcl",
label: "HCL",
loader: () => import("refractor/lang/hcl").then((m) => m.default),
},
markup: {
lang: "markup",
label: "HTML",
loader: () => import("refractor/lang/markup").then((m) => m.default),
},
ini: {
lang: "ini",
label: "INI",
loader: () => import("refractor/lang/ini").then((m) => m.default),
},
java: {
lang: "java",
label: "Java",
loader: () => import("refractor/lang/java").then((m) => m.default),
},
javascript: {
lang: "javascript",
label: "JavaScript",
loader: () => import("refractor/lang/javascript").then((m) => m.default),
},
json: {
lang: "json",
label: "JSON",
loader: () => import("refractor/lang/json").then((m) => m.default),
},
jsx: {
lang: "jsx",
label: "JSX",
loader: () => import("refractor/lang/jsx").then((m) => m.default),
},
kotlin: {
lang: "kotlin",
label: "Kotlin",
loader: () => import("refractor/lang/kotlin").then((m) => m.default),
},
kusto: {
lang: "kusto",
label: "Kusto",
// @ts-expect-error Mermaid is not in types but exists
loader: () => import("refractor/lang/kusto").then((m) => m.default),
},
lisp: {
lang: "lisp",
label: "Lisp",
loader: () => import("refractor/lang/lisp").then((m) => m.default),
},
lua: {
lang: "lua",
label: "Lua",
loader: () => import("refractor/lang/lua").then((m) => m.default),
},
makefile: {
lang: "makefile",
label: "Makefile",
loader: () => import("refractor/lang/makefile").then((m) => m.default),
},
markdown: {
lang: "markdown",
label: "Markdown",
loader: () => import("refractor/lang/markdown").then((m) => m.default),
},
mermaidjs: {
lang: "mermaid",
label: "Mermaid Diagram",
// @ts-expect-error Mermaid is not in types but exists
loader: () => import("refractor/lang/mermaid").then((m) => m.default),
},
nginx: {
lang: "nginx",
label: "Nginx",
loader: () => import("refractor/lang/nginx").then((m) => m.default),
},
nix: {
lang: "nix",
label: "Nix",
loader: () => import("refractor/lang/nix").then((m) => m.default),
},
objectivec: {
lang: "objectivec",
label: "Objective-C",
loader: () => import("refractor/lang/objectivec").then((m) => m.default),
},
ocaml: {
lang: "ocaml",
label: "OCaml",
loader: () => import("refractor/lang/ocaml").then((m) => m.default),
},
perl: {
lang: "perl",
label: "Perl",
loader: () => import("refractor/lang/perl").then((m) => m.default),
},
php: {
lang: "php",
label: "PHP",
loader: () => import("refractor/lang/php").then((m) => m.default),
},
powershell: {
lang: "powershell",
label: "Powershell",
loader: () => import("refractor/lang/powershell").then((m) => m.default),
},
protobuf: {
lang: "protobuf",
label: "Protobuf",
loader: () => import("refractor/lang/protobuf").then((m) => m.default),
},
python: {
lang: "python",
label: "Python",
loader: () => import("refractor/lang/python").then((m) => m.default),
},
r: {
lang: "r",
label: "R",
loader: () => import("refractor/lang/r").then((m) => m.default),
},
regex: {
lang: "regex",
label: "Regex",
loader: () => import("refractor/lang/regex").then((m) => m.default),
},
ruby: {
lang: "ruby",
label: "Ruby",
loader: () => import("refractor/lang/ruby").then((m) => m.default),
},
rust: {
lang: "rust",
label: "Rust",
loader: () => import("refractor/lang/rust").then((m) => m.default),
},
scala: {
lang: "scala",
label: "Scala",
loader: () => import("refractor/lang/scala").then((m) => m.default),
},
sass: {
lang: "sass",
label: "Sass",
loader: () => import("refractor/lang/sass").then((m) => m.default),
},
scss: {
lang: "scss",
label: "SCSS",
loader: () => import("refractor/lang/scss").then((m) => m.default),
},
"splunk-spl": {
lang: "splunk-spl",
label: "Splunk SPL",
loader: () => import("refractor/lang/splunk-spl").then((m) => m.default),
},
sql: {
lang: "sql",
label: "SQL",
loader: () => import("refractor/lang/sql").then((m) => m.default),
},
solidity: {
lang: "solidity",
label: "Solidity",
loader: () => import("refractor/lang/solidity").then((m) => m.default),
},
swift: {
lang: "swift",
label: "Swift",
loader: () => import("refractor/lang/swift").then((m) => m.default),
},
toml: {
lang: "toml",
label: "TOML",
loader: () => import("refractor/lang/toml").then((m) => m.default),
},
tsx: {
lang: "tsx",
label: "TSX",
loader: () => import("refractor/lang/tsx").then((m) => m.default),
},
typescript: {
lang: "typescript",
label: "TypeScript",
loader: () => import("refractor/lang/typescript").then((m) => m.default),
},
vb: {
lang: "vbnet",
label: "Visual Basic",
loader: () => import("refractor/lang/vbnet").then((m) => m.default),
},
verilog: {
lang: "verilog",
label: "Verilog",
loader: () => import("refractor/lang/verilog").then((m) => m.default),
},
vhdl: {
lang: "vhdl",
label: "VHDL",
loader: () => import("refractor/lang/vhdl").then((m) => m.default),
},
yaml: {
lang: "yaml",
label: "YAML",
loader: () => import("refractor/lang/yaml").then((m) => m.default),
},
xml: {
lang: "markup",
label: "XML",
loader: () => import("refractor/lang/markup").then((m) => m.default),
},
zig: {
lang: "zig",
label: "Zig",
loader: () => import("refractor/lang/zig").then((m) => m.default),
},
};
/**
@@ -79,27 +323,38 @@ export const getLabelForLanguage = (language: string) => {
};
/**
* Get the Prism language identifier for a given language.
* Get the Refractor language identifier for a given language.
*
* @param language The language identifier.
* @returns The Prism language identifier for the language.
* @returns The Refractor language identifier for the language.
*/
export const getPrismLangForLanguage = (language: string): string | undefined =>
export const getRefractorLangForLanguage = (
language: string
): string | undefined =>
codeLanguages[language as keyof typeof codeLanguages]?.lang;
/**
* Get the loader function for a given language.
*
* @param language The language identifier.
* @returns The loader function for the language, or undefined if not available.
*/
export const getLoaderForLanguage = (language: string) =>
codeLanguages[language as keyof typeof codeLanguages]?.loader;
/**
* Set the most recent code language used.
*
* @param language The language identifier.
*/
export const setRecentCodeLanguage = (language: string) => {
export const setRecentlyUsedCodeLanguage = (language: string) => {
const frequentLangs = (Storage.get(StorageKey) ?? {}) as Record<
string,
number
>;
if (Object.keys(frequentLangs).length === 0) {
const lastUsedLang = Storage.get(RecentStorageKey);
const lastUsedLang = Storage.get(RecentlyUsedStorageKey);
if (lastUsedLang) {
frequentLangs[lastUsedLang] = 1;
}
@@ -121,7 +376,7 @@ export const setRecentCodeLanguage = (language: string) => {
}
Storage.set(StorageKey, Object.fromEntries(frequentLangEntries));
Storage.set(RecentStorageKey, language);
Storage.set(RecentlyUsedStorageKey, language);
};
/**
@@ -129,8 +384,8 @@ export const setRecentCodeLanguage = (language: string) => {
*
* @returns The most recent code language used, or undefined if none is set.
*/
export const getRecentCodeLanguage = () =>
Storage.get(RecentStorageKey) as keyof typeof codeLanguages | undefined;
export const getRecentlyUsedCodeLanguage = () =>
Storage.get(RecentlyUsedStorageKey) as keyof typeof codeLanguages | undefined;
/**
* Get the most frequent code languages used.
@@ -138,7 +393,7 @@ export const getRecentCodeLanguage = () =>
* @returns An array of the most frequent code languages used.
*/
export const getFrequentCodeLanguages = () => {
const recentLang = Storage.get(RecentStorageKey);
const recentLang = Storage.get(RecentlyUsedStorageKey);
const frequentLangEntries = Object.entries(Storage.get(StorageKey) ?? {}) as [
keyof typeof codeLanguages,
number
+5 -3
View File
@@ -28,9 +28,10 @@ export function getCellAttrs(dom: HTMLElement | string): Attrs {
const widthAttr = dom.getAttribute("data-colwidth");
const widths =
widthAttr && /^\d+(,\d+)*$/.test(widthAttr)
? widthAttr.split(",").map((s) => Number(s))
? widthAttr.split(",").map(Number)
: null;
const colspan = Number(dom.getAttribute("colspan") || 1);
return {
colspan,
rowspan: Number(dom.getAttribute("rowspan") || 1),
@@ -63,10 +64,11 @@ export function setCellAttrs(node: Node): Attrs {
}
if (node.attrs.colwidth) {
if (isBrowser) {
attrs["data-colwidth"] = node.attrs.colwidth.join(",");
attrs["data-colwidth"] = node.attrs.colwidth.map(parseInt).join(",");
} else {
attrs.style =
(attrs.style ?? "") + `min-width: ${node.attrs.colwidth}px;`;
(attrs.style ?? "") +
`min-width: ${parseInt(node.attrs.colwidth[0])}px;`;
}
}
+1
View File
@@ -24,6 +24,7 @@ export default class Code extends Mark {
excludes: "mention placeholder highlight",
parseDOM: [{ tag: "code", preserveWhitespace: true }],
toDOM: () => ["code", { class: "inline", spellCheck: "false" }],
code: true,
};
}
+9 -110
View File
@@ -9,58 +9,6 @@ import {
} from "prosemirror-model";
import { Command, Plugin, PluginKey, TextSelection } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import refractor from "refractor/core";
import bash from "refractor/lang/bash";
import clike from "refractor/lang/clike";
import cpp from "refractor/lang/cpp";
import csharp from "refractor/lang/csharp";
import css from "refractor/lang/css";
import docker from "refractor/lang/docker";
import elixir from "refractor/lang/elixir";
import erlang from "refractor/lang/erlang";
import go from "refractor/lang/go";
import graphql from "refractor/lang/graphql";
import groovy from "refractor/lang/groovy";
import haskell from "refractor/lang/haskell";
import hcl from "refractor/lang/hcl";
import ini from "refractor/lang/ini";
import java from "refractor/lang/java";
import javascript from "refractor/lang/javascript";
import json from "refractor/lang/json";
import jsx from "refractor/lang/jsx";
import kotlin from "refractor/lang/kotlin";
import lisp from "refractor/lang/lisp";
import lua from "refractor/lang/lua";
import markup from "refractor/lang/markup";
// @ts-expect-error type definition is missing, but package exists
import mermaid from "refractor/lang/mermaid";
import nginx from "refractor/lang/nginx";
import nix from "refractor/lang/nix";
import objectivec from "refractor/lang/objectivec";
import ocaml from "refractor/lang/ocaml";
import perl from "refractor/lang/perl";
import php from "refractor/lang/php";
import powershell from "refractor/lang/powershell";
import protobuf from "refractor/lang/protobuf";
import python from "refractor/lang/python";
import r from "refractor/lang/r";
import ruby from "refractor/lang/ruby";
import rust from "refractor/lang/rust";
import sass from "refractor/lang/sass";
import scala from "refractor/lang/scala";
import scss from "refractor/lang/scss";
import solidity from "refractor/lang/solidity";
import sql from "refractor/lang/sql";
import swift from "refractor/lang/swift";
import toml from "refractor/lang/toml";
import tsx from "refractor/lang/tsx";
import typescript from "refractor/lang/typescript";
import verilog from "refractor/lang/verilog";
import vhdl from "refractor/lang/vhdl";
import visualbasic from "refractor/lang/visual-basic";
import yaml from "refractor/lang/yaml";
import zig from "refractor/lang/zig";
import { toast } from "sonner";
import { Primitive } from "utility-types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -77,9 +25,12 @@ import {
} from "../commands/codeFence";
import { selectAll } from "../commands/selectAll";
import toggleBlockType from "../commands/toggleBlockType";
import { CodeHighlighting } from "../extensions/CodeHighlighting";
import Mermaid from "../extensions/Mermaid";
import Prism from "../extensions/Prism";
import { getRecentCodeLanguage, setRecentCodeLanguage } from "../lib/code";
import {
getRecentlyUsedCodeLanguage,
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import { isCode } from "../lib/isCode";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
@@ -90,58 +41,6 @@ import Node from "./Node";
const DEFAULT_LANGUAGE = "javascript";
[
bash,
cpp,
css,
clike,
csharp,
docker,
elixir,
erlang,
go,
graphql,
groovy,
haskell,
hcl,
ini,
java,
javascript,
jsx,
json,
kotlin,
lisp,
lua,
markup,
mermaid,
nginx,
nix,
objectivec,
ocaml,
perl,
php,
python,
powershell,
protobuf,
r,
ruby,
rust,
scala,
sql,
solidity,
sass,
scss,
swift,
toml,
typescript,
tsx,
verilog,
vhdl,
visualbasic,
yaml,
zig,
].forEach(refractor.register);
export default class CodeFence extends Node {
constructor(options: {
dictionary: Dictionary;
@@ -212,10 +111,10 @@ export default class CodeFence extends Node {
return {
code_block: (attrs: Record<string, Primitive>) => {
if (attrs?.language) {
setRecentCodeLanguage(attrs.language as string);
setRecentlyUsedCodeLanguage(attrs.language as string);
}
return toggleBlockType(type, schema.nodes.paragraph, {
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
...attrs,
});
},
@@ -286,7 +185,7 @@ export default class CodeFence extends Node {
get plugins() {
return [
Prism({
CodeHighlighting({
name: this.name,
lineNumbers: this.showLineNumbers,
}),
@@ -353,7 +252,7 @@ export default class CodeFence extends Node {
inputRules({ type }: { type: NodeType }) {
return [
textblockTypeInputRule(/^```$/, type, () => ({
language: getRecentCodeLanguage() ?? DEFAULT_LANGUAGE,
language: getRecentlyUsedCodeLanguage() ?? DEFAULT_LANGUAGE,
})),
];
}
+5 -1
View File
@@ -408,10 +408,14 @@ export default class Image extends SimpleImage {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
let layoutClass: string | null = "full-width";
if (state.selection.node.attrs.layoutClass === layoutClass) {
layoutClass = null;
}
const attrs = {
...state.selection.node.attrs,
title: null,
layoutClass: "full-width",
layoutClass,
};
const { selection } = state;
dispatch?.(state.tr.setNodeMarkup(selection.from, undefined, attrs));
+88 -15
View File
@@ -1,3 +1,4 @@
import isMatch from "lodash/isMatch";
import { Token } from "markdown-it";
import {
NodeSpec,
@@ -15,10 +16,12 @@ import * as React from "react";
import { Primitive } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import { MentionType } from "../../types";
import { MentionType, UnfurlResourceType, UnfurlResponse } from "../../types";
import {
MentionCollection,
MentionDocument,
MentionIssue,
MentionPullRequest,
MentionUser,
} from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
@@ -50,6 +53,12 @@ export default class Mention extends Node {
id: {
default: undefined,
},
href: {
default: undefined,
},
unfurl: {
default: undefined,
},
},
inline: true,
marks: "",
@@ -73,6 +82,10 @@ export default class Mention extends Node {
actorId: dom.dataset.actorid,
label: dom.innerText,
id: dom.id,
href: dom.getAttribute("href"),
unfurl: dom.dataset.unfurl
? JSON.parse(dom.dataset.unfurl)
: undefined,
};
},
},
@@ -87,11 +100,18 @@ export default class Mention extends Node {
? undefined
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
: `${env.URL}/collection/${node.attrs.modelId}`,
: node.attrs.type === MentionType.Collection
? `${env.URL}/collection/${node.attrs.modelId}`
: node.attrs.href,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorid": node.attrs.actorId,
"data-url": `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
"data-url":
node.attrs.type === MentionType.PullRequest ||
node.attrs.type === MentionType.Issue
? node.attrs.href
: `mention://${node.attrs.id}/${node.attrs.type}/${node.attrs.modelId}`,
"data-unfurl": JSON.stringify(node.attrs.unfurl),
},
toPlainText(node),
],
@@ -107,6 +127,20 @@ export default class Mention extends Node {
return <MentionDocument {...props} />;
case MentionType.Collection:
return <MentionCollection {...props} />;
case MentionType.Issue:
return (
<MentionIssue
{...props}
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionType.PullRequest:
return (
<MentionPullRequest
{...props}
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
default:
return null;
}
@@ -149,29 +183,42 @@ export default class Mention extends Node {
}
keys(): Record<string, Command> {
const NavigableMention = [
MentionType.Collection,
MentionType.Document,
MentionType.Issue,
MentionType.PullRequest,
];
return {
Enter: (state) => {
const { selection } = state;
if (
selection instanceof NodeSelection &&
selection.node.type.name === this.name &&
(selection.node.attrs.type === MentionType.Document ||
selection.node.attrs.type === MentionType.Collection)
NavigableMention.includes(selection.node.attrs.type)
) {
const { modelId } = selection.node.attrs;
const mentionType = selection.node.attrs.type;
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: selection.node.attrs.type === MentionType.Collection
? "collection"
: undefined;
let link: string;
if (!linkType) {
return false;
if (
mentionType === MentionType.Issue ||
mentionType === MentionType.PullRequest
) {
link = selection.node.attrs.href;
} else {
const { modelId } = selection.node.attrs;
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: "collection";
link = `/${linkType}/${modelId}`;
}
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
this.editor.props.onClickLink?.(link);
return true;
}
return false;
@@ -218,4 +265,30 @@ export default class Mention extends Node {
}),
};
}
handleChangeUnfurl =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
const { view } = this.editor;
const { tr } = view.state;
const label =
unfurl.type === UnfurlResourceType.Issue ||
unfurl.type === UnfurlResourceType.PR
? unfurl.title
: undefined;
const overrides: Record<string, unknown> = label ? { label } : {};
overrides.unfurl = unfurl;
const pos = getPos();
if (!isMatch(node.attrs, overrides)) {
const transaction = tr.setNodeMarkup(pos, undefined, {
...node.attrs,
...overrides,
});
view.dispatch(transaction);
}
};
}
+16 -4
View File
@@ -1,5 +1,5 @@
import { NodeType } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import { EditorState, NodeSelection } from "prosemirror-state";
import { Primitive } from "utility-types";
import { findParentNode } from "./findParentNode";
@@ -25,12 +25,24 @@ export const isNodeActive =
return false;
}
let nodeWithPos;
const { from, to } = state.selection;
const nodeWithPos = findParentNode(
if (
state.selection instanceof NodeSelection &&
state.selection.node.type === type &&
state.selection.node.hasMarkup(type, {
...state.selection.node.attrs,
...attrs,
})
) {
nodeWithPos = { pos: from, node: state.selection.node };
}
nodeWithPos ??= findParentNode(
(node) =>
node.type === type &&
(!attrs ||
Object.keys(attrs).every((key) => node.attrs[key] === attrs[key]))
(!attrs || node.hasMarkup(type, { ...node.attrs, ...attrs }))
)(state.selection);
if (!nodeWithPos) {
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
/**
* Hook to check if component is still mounted
*
* @returns {boolean} true if the component is mounted, false otherwise
*/
export default function useIsMounted() {
const isMounted = React.useRef(false);
React.useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return React.useCallback(() => isMounted.current, []);
}
+6 -9
View File
@@ -57,6 +57,8 @@
"Download document": "Download document",
"Copy as Markdown": "Copy as Markdown",
"Markdown copied to clipboard": "Markdown copied to clipboard",
"Copy as text": "Copy as text",
"Text copied to clipboard": "Text copied to clipboard",
"Copy public link": "Copy public link",
"Link copied to clipboard": "Link copied to clipboard",
"Copy link": "Copy link",
@@ -426,6 +428,7 @@
"Create a new doc": "Create a new doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Keep as link": "Keep as link",
"Mention": "Mention",
"Embed": "Embed",
"Add column after": "Add column after",
"Add column before": "Add column before",
@@ -517,7 +520,6 @@
"Groups": "Groups",
"Shared Links": "Shared Links",
"Import": "Import",
"Self Hosted": "Self Hosted",
"Integrations": "Integrations",
"Revoke token": "Revoke token",
"Revoke": "Revoke",
@@ -891,6 +893,8 @@
"No people left to add": "No people left to add",
"Date created": "Date created",
"Upload": "Upload",
"Crop image": "Crop image",
"Uploading": "Uploading",
"How does this work?": "How does this work?",
"You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from the JSON option in another instance. In {{ appName }}, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
@@ -903,9 +907,6 @@
"{{ count }} document imported_plural": "{{ count }} documents imported",
"You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
"Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload",
"Where do I find the file?": "Where do I find the file?",
"In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.": "In Notion, click <em>Settings & Members</em> in the left sidebar and open Settings. Look for the Export section, and click <em>Export all workspace content</em>. Choose <em>HTML</em> as the format for the best data compatability.",
"Drag and drop the zip file from Notion's HTML export option, or click to upload": "Drag and drop the zip file from Notion's HTML export option, or click to upload",
"Last active": "Last active",
"Guest": "Guest",
"Shared by": "Shared by",
@@ -1043,10 +1044,6 @@
"Allow editors to create new collections within the workspace": "Allow editors to create new collections within the workspace",
"Workspace creation": "Workspace creation",
"Allow editors to create new workspaces": "Allow editors to create new workspaces",
"Draw.io deployment": "Draw.io deployment",
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
"Grist deployment": "Grist deployment",
"Add your self-hosted grist installation URL here.": "Add your self-hosted grist installation URL here.",
"Could not load shares": "Could not load shares",
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",
@@ -1155,5 +1152,5 @@
"{{ user }} updated {{ timeAgo }}": "{{ user }} updated {{ timeAgo }}",
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
"Error loading data": "Error loading data"
}

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