mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd08bbf58 | |||
| bda95e4952 | |||
| 394c6e3b03 | |||
| 9113501906 | |||
| 92168c3641 | |||
| 5ea63aa1a2 | |||
| b1bf7c488b | |||
| 9811ab6aea | |||
| f0899f614b | |||
| c65b020655 | |||
| 9791ff1170 | |||
| a25f334bb1 |
@@ -140,6 +140,11 @@ FORCE_HTTPS=true
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
|
||||
# running behind a proxy in production.
|
||||
# PROXY_HEADERS_TRUSTED=true
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request' &&
|
||||
steps.calibre.outputs.markdown != ''
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
|
||||
with:
|
||||
title: "chore: Auto Compress Images"
|
||||
branch-suffix: timestamp
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
|
||||
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DateSection = ({ t }: ActionContext) => t("Date");
|
||||
|
||||
DateSection.priority = 1;
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { runInAction } from "mobx";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
@@ -14,11 +15,20 @@ import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "@shared/utils/date";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { DynamicCalendarIcon } from "@shared/components/DynamicCalendarIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DateSection,
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
@@ -26,18 +36,20 @@ import {
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
modelId: string;
|
||||
label: string;
|
||||
// Date mentions intentionally omit a label — their text is derived from
|
||||
// the ISO `modelId` so nothing human-readable is persisted.
|
||||
label?: string;
|
||||
actorId?: string;
|
||||
};
|
||||
}
|
||||
@@ -54,8 +66,65 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const userLocale = useUserLocale();
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
// Surface a date suggestion when the search query parses as a natural
|
||||
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
|
||||
// asynchronous as chrono-node is loaded lazily, so the result is held in
|
||||
// state and applied once resolved.
|
||||
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
setParsedISODate(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void parseNaturalLanguageDate(search)
|
||||
.then((date) => {
|
||||
if (!cancelled) {
|
||||
setParsedISODate(date ? toISODate(date) : undefined);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Parsing failed (e.g. the chrono chunk failed to load); drop the
|
||||
// suggestion rather than leaving a stale one.
|
||||
if (!cancelled) {
|
||||
setParsedISODate(undefined);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
let dateItems: MentionItem[] = [];
|
||||
|
||||
if (actorId && parsedISODate) {
|
||||
const title = dateToRelativeReadable(parsedISODate, t, userLocale);
|
||||
const subtitle = dateToReadable(parsedISODate, userLocale);
|
||||
|
||||
dateItems = [
|
||||
{
|
||||
name: "mention",
|
||||
icon: (
|
||||
<DynamicCalendarIcon day={parseISODate(parsedISODate)?.getDate()} />
|
||||
),
|
||||
title,
|
||||
subtitle: title !== subtitle ? subtitle : undefined,
|
||||
section: DateSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: parsedISODate,
|
||||
actorId,
|
||||
},
|
||||
} as MentionItem,
|
||||
];
|
||||
}
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
@@ -87,7 +156,7 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
const mentionItems: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
@@ -253,9 +322,12 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
])
|
||||
: [];
|
||||
|
||||
const items: MentionItem[] = [...dateItems, ...mentionItems];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (
|
||||
item.attrs.type === MentionType.Date ||
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -107,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
|
||||
|
||||
return {
|
||||
style: `background-color: ${u.color}${opacity}`,
|
||||
class: "ProseMirror-yjs-selection",
|
||||
class: EditorStyleHelper.multiplayerSelection,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default class Suggestion<
|
||||
: `(?:${triggers.map(escapeRegExp).join("|")})`;
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
|
||||
+13
-15
@@ -17,7 +17,6 @@ import {
|
||||
WarningIcon,
|
||||
InfoIcon,
|
||||
AttachmentIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
@@ -26,9 +25,12 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { TFunction } from "i18next";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { toISODate } from "@shared/utils/date";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
@@ -184,22 +186,18 @@ export default function blockMenuItems(
|
||||
attrs: { markup: "***" },
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
// Inserts a date mention for today. Supersedes the deprecated "Current
|
||||
// date/time" commands that inserted a static string or template token.
|
||||
name: "mention",
|
||||
title: t("Current date"),
|
||||
keywords: "clock today",
|
||||
icon: <CalendarIcon />,
|
||||
},
|
||||
{
|
||||
name: "time",
|
||||
title: t("Current time"),
|
||||
keywords: "clock now",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
{
|
||||
name: "datetime",
|
||||
title: t("Current date and time"),
|
||||
keywords: "clock today date",
|
||||
keywords: "clock today date time now",
|
||||
icon: <CalendarIcon />,
|
||||
appendSpace: true,
|
||||
attrs: () => ({
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: toISODate(new Date()),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import { CalendarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "@shared/components/Calendar";
|
||||
import { dateLocale } from "@shared/utils/date";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
@@ -21,25 +20,10 @@ type Props = {
|
||||
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
|
||||
const styles = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
"--rdp-caption-font-size": "16px",
|
||||
"--rdp-cell-size": "34px",
|
||||
"--rdp-selected-text": theme.accentText,
|
||||
"--rdp-accent-color": theme.accent,
|
||||
"--rdp-accent-color-dark": theme.accent,
|
||||
"--rdp-background-color": theme.listItemHoverBackground,
|
||||
"--rdp-background-color-dark": theme.listItemHoverBackground,
|
||||
}) as React.CSSProperties,
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
@@ -51,7 +35,7 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledPopoverButton icon={<Icon />} neutral>
|
||||
<StyledPopoverButton neutral>
|
||||
{selectedDate
|
||||
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
|
||||
: t("Choose a date")}
|
||||
@@ -63,12 +47,12 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
side="right"
|
||||
shrink
|
||||
>
|
||||
<DayPicker
|
||||
<Calendar
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
style={styles}
|
||||
locale={locale}
|
||||
disabled={{ before: new Date() }}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -76,23 +60,9 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = () => (
|
||||
<IconWrapper>
|
||||
<CalendarIcon />
|
||||
</IconWrapper>
|
||||
);
|
||||
|
||||
const StyledPopoverButton = styled(Button)`
|
||||
margin-top: 12px;
|
||||
width: 150px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default ExpiryDatePicker;
|
||||
|
||||
@@ -13,7 +13,6 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
import "react-day-picker/dist/style.css";
|
||||
import ExpiryDatePicker from "./components/ExpiryDatePicker";
|
||||
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
|
||||
|
||||
@@ -123,7 +122,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<Flex align="center" gap={8}>
|
||||
<StyledExpirySelect
|
||||
options={expiryOptions}
|
||||
value={expiryType}
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||
@@ -157,6 +158,30 @@ function CommentForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// "+:emoji:" shorthand: react to the comment above instead of replying.
|
||||
if (thread && !thread.isNew) {
|
||||
const emoji = parseReactionShorthand(draft);
|
||||
if (emoji) {
|
||||
const target = comments
|
||||
.inThread(thread.id)
|
||||
.filter((comment) => !comment.isNew)
|
||||
.pop();
|
||||
|
||||
if (target) {
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
void target.addReaction({ emoji, user });
|
||||
onSubmit?.();
|
||||
|
||||
// re-focus the comment editor
|
||||
setTimeout(() => {
|
||||
editorRef.current?.focusAtStart();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commentDraft = draft;
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"bull": "^4.16.5",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"command-score": "^0.1.2",
|
||||
"compressorjs": "^1.3.0",
|
||||
|
||||
@@ -372,6 +372,16 @@ export class Environment {
|
||||
@IsOptional()
|
||||
public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);
|
||||
|
||||
/**
|
||||
* Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
* X-Forwarded-Proto) set by an upstream proxy or load balancer. Defaults to
|
||||
* true for backwards compat. Set to false if not running behind a proxy in production.
|
||||
*/
|
||||
@IsBoolean()
|
||||
public PROXY_HEADERS_TRUSTED = this.toBoolean(
|
||||
environment.PROXY_HEADERS_TRUSTED ?? "true"
|
||||
);
|
||||
|
||||
/**
|
||||
* Should the installation send anonymized statistics to the maintainers.
|
||||
* Defaults to true.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.removeColumn("teams", "collaborativeEditing");
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("teams", "collaborativeEditing", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -150,26 +150,12 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
includeAttachments = true
|
||||
) {
|
||||
const pathMap = this.createPathMap(collections, format);
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Start adding ${Object.values(pathMap).length} documents to archive`
|
||||
);
|
||||
|
||||
for (const path of pathMap) {
|
||||
const documentId = path[0].replace("/doc/", "");
|
||||
const pathInZip = path[1];
|
||||
|
||||
await this.processDocument({
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
includeAttachments,
|
||||
format,
|
||||
pathMap,
|
||||
});
|
||||
}
|
||||
|
||||
Logger.debug("task", "Completed adding documents to archive");
|
||||
await this.addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments,
|
||||
});
|
||||
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
@@ -200,28 +186,58 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
format
|
||||
);
|
||||
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Start adding ${Object.values(pathMap).length} documents to archive`
|
||||
);
|
||||
await this.addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments: true,
|
||||
});
|
||||
|
||||
for (const entry of pathMap) {
|
||||
const documentId = entry[0].replace("/doc/", "");
|
||||
const pathInZip = entry[1];
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes each unique document in the path map and adds it to the zip.
|
||||
*
|
||||
* @param zip The yazl ZipFile to add files to
|
||||
* @param pathMap Map of document urls to their path in the zip
|
||||
* @param format The format to export in
|
||||
* @param includeAttachments Whether to include attachments in the export
|
||||
*/
|
||||
private async addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments,
|
||||
}: {
|
||||
zip: ZipFile;
|
||||
pathMap: Map<string, string>;
|
||||
format: FileOperationFormat;
|
||||
includeAttachments: boolean;
|
||||
}) {
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
Logger.debug("task", `Start adding documents to archive`);
|
||||
|
||||
for (const [url, pathInZip] of pathMap) {
|
||||
// A document may be keyed by multiple urls in the path map, only
|
||||
// process each file in the zip once.
|
||||
if (processedPaths.has(pathInZip)) {
|
||||
continue;
|
||||
}
|
||||
processedPaths.add(pathInZip);
|
||||
|
||||
await this.processDocument({
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
includeAttachments: true,
|
||||
documentId: url.replace("/doc/", ""),
|
||||
includeAttachments,
|
||||
format,
|
||||
pathMap,
|
||||
});
|
||||
}
|
||||
|
||||
Logger.debug("task", "Completed adding documents to archive");
|
||||
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "fs-extra";
|
||||
import ZipHelper from "@server/utils/ZipHelper";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildFileOperation,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import ExportMarkdownZipTask from "./ExportMarkdownZipTask";
|
||||
|
||||
describe("ExportMarkdownZipTask", () => {
|
||||
it("should not duplicate documents in the zip file", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
const documents = await Promise.all([
|
||||
buildDocument({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Test1",
|
||||
}),
|
||||
buildDocument({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Test2",
|
||||
}),
|
||||
]);
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document);
|
||||
}
|
||||
const fileOperation = await buildFileOperation({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const task = new ExportMarkdownZipTask();
|
||||
const filePath = await task.exportCollections([collection], fileOperation);
|
||||
|
||||
try {
|
||||
const fileNames: string[] = [];
|
||||
await ZipHelper.walk(filePath, (entry) => {
|
||||
if (!entry.isDirectory) {
|
||||
fileNames.push(entry.fileName);
|
||||
}
|
||||
});
|
||||
|
||||
expect(fileNames.sort()).toEqual([
|
||||
`${collection.name}/Test1.md`,
|
||||
`${collection.name}/Test2.md`,
|
||||
]);
|
||||
} finally {
|
||||
await fs.remove(filePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1257,6 +1257,23 @@ describe("#collections.create", () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("rejects providing both description and data", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/collections.create", user, {
|
||||
body: {
|
||||
name: "Test",
|
||||
description: "Test",
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow setting sharing to false", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/collections.create", user, {
|
||||
@@ -1448,6 +1465,50 @@ describe("#collections.update", () => {
|
||||
expect(collection.content).toBeTruthy();
|
||||
});
|
||||
|
||||
it("replaces rendered content when description is updated post-create", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
const createRes = await server.post("/api/collections.create", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { name: "Foo", description: "Original" },
|
||||
});
|
||||
const { id } = (await createRes.json()).data;
|
||||
|
||||
const updateRes = await server.post("/api/collections.update", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { id, description: "Replaced" },
|
||||
});
|
||||
expect(updateRes.status).toEqual(200);
|
||||
|
||||
const infoRes = await server.post("/api/collections.info", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { id },
|
||||
});
|
||||
const content = JSON.stringify((await infoRes.json()).data.data);
|
||||
expect(content).toContain("Replaced");
|
||||
expect(content).not.toContain("Original");
|
||||
});
|
||||
|
||||
it("rejects providing both description and data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const res = await server.post("/api/collections.update", admin, {
|
||||
body: {
|
||||
id: collection.id,
|
||||
description: "Test",
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("allows editing data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
@@ -15,39 +15,50 @@ const BaseIdSchema = z.object({
|
||||
id: zodIdType(),
|
||||
});
|
||||
|
||||
/** The landing page can be set from description (markdown) or data (rich content), but not both. */
|
||||
const refineBodyContent = <T extends { description?: unknown; data?: unknown }>(
|
||||
body: T
|
||||
) => isUndefined(body.description) || isUndefined(body.data);
|
||||
|
||||
const bodyContentError = {
|
||||
error: "Only one of description or data may be provided",
|
||||
};
|
||||
|
||||
export const CollectionsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
|
||||
permission: z
|
||||
.enum(CollectionPermission)
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().prefault(true),
|
||||
icon: zodIconType().optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
direction: z.union([z.literal("asc"), z.literal("desc")]),
|
||||
})
|
||||
.prefault(Collection.DEFAULT_SORT),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
|
||||
.max(ValidateIndex.maxLength, {
|
||||
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.prefault(CollectionPermission.Admin),
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
|
||||
permission: z
|
||||
.enum(CollectionPermission)
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().prefault(true),
|
||||
icon: zodIconType().optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
direction: z.union([z.literal("asc"), z.literal("desc")]),
|
||||
})
|
||||
.prefault(Collection.DEFAULT_SORT),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
|
||||
.max(ValidateIndex.maxLength, {
|
||||
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.prefault(CollectionPermission.Admin),
|
||||
})
|
||||
.refine(refineBodyContent, bodyContentError),
|
||||
});
|
||||
|
||||
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
|
||||
@@ -188,7 +199,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.optional(),
|
||||
}),
|
||||
}).refine(refineBodyContent, bodyContentError),
|
||||
});
|
||||
|
||||
export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
|
||||
|
||||
+14
-7
@@ -29,6 +29,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
void initI18n();
|
||||
|
||||
if (env.isProduction) {
|
||||
// Trust the X-Forwarded-* headers set by an upstream proxy, eg
|
||||
// X-Forwarded-For. Defaults to true, but can be disabled with
|
||||
// PROXY_HEADERS_TRUSTED when the app is reachable directly.
|
||||
if (env.PROXY_HEADERS_TRUSTED) {
|
||||
app.proxy = true;
|
||||
if (env.PROXY_IP_HEADER) {
|
||||
app.proxyIpHeader = env.PROXY_IP_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// Force redirect to HTTPS protocol unless explicitly disabled
|
||||
if (env.FORCE_HTTPS) {
|
||||
app.use(
|
||||
@@ -37,19 +47,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
if (httpsResolver(ctx)) {
|
||||
return true;
|
||||
}
|
||||
return xForwardedProtoResolver(ctx);
|
||||
// Only honor X-Forwarded-Proto when proxy headers are trusted
|
||||
return env.PROXY_HEADERS_TRUSTED
|
||||
? xForwardedProtoResolver(ctx)
|
||||
: false;
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
Logger.warn("Enforced https was disabled with FORCE_HTTPS env variable");
|
||||
}
|
||||
|
||||
// trust header fields set by our proxy. eg X-Forwarded-For
|
||||
app.proxy = true;
|
||||
if (env.PROXY_IP_HEADER) {
|
||||
app.proxyIpHeader = env.PROXY_IP_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// Make `ctx.userAgent` available
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import styled from "styled-components";
|
||||
import { s } from "../styles";
|
||||
|
||||
type Props = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
/**
|
||||
* A themed calendar built on react-day-picker. It is styled from scratch (the
|
||||
* library's base stylesheet is intentionally not relied upon) so that it looks
|
||||
* consistent everywhere it is used. Outside (previous/next month) days are
|
||||
* shown de-emphasised, the selected day is a solid accent-filled circle, and
|
||||
* today is highlighted with the accent colour.
|
||||
*
|
||||
* @param props the underlying react-day-picker props; `showOutsideDays` and
|
||||
* `fixedWeeks` default to true but may be overridden.
|
||||
* @returns the rendered calendar.
|
||||
*/
|
||||
export function Calendar(props: Props) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<DayPicker showOutsideDays fixedWeeks {...props} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
padding: 12px;
|
||||
color: ${s("text")};
|
||||
|
||||
.rdp {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Visually-hidden accessibility labels (would otherwise show without the
|
||||
base stylesheet). */
|
||||
.rdp-vhidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.rdp-month {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp-caption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
|
||||
.rdp-caption_label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${s("text")};
|
||||
}
|
||||
|
||||
.rdp-nav {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rdp-nav_button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
color: ${s("textSecondary")};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
|
||||
&:hover {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
}
|
||||
|
||||
.rdp-nav_icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.rdp-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rdp-head_cell {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
color: ${s("textTertiary")};
|
||||
padding: 4px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rdp-cell {
|
||||
padding: 1px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rdp-day {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
background: none;
|
||||
border-radius: 50%;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: ${s("text")};
|
||||
cursor: pointer;
|
||||
transition: background 100ms ease;
|
||||
|
||||
&:hover:not([disabled]):not(.rdp-day_selected) {
|
||||
background: ${s("listItemHoverBackground")};
|
||||
}
|
||||
|
||||
&:focus-visible:not([disabled]) {
|
||||
outline: 2px solid ${s("accent")};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Today, when not selected, is emphasised with the accent colour. */
|
||||
.rdp-day_today:not(.rdp-day_selected) {
|
||||
font-weight: 700;
|
||||
color: ${s("accent")};
|
||||
}
|
||||
|
||||
/* Days belonging to the previous/next month are clearly de-emphasised. */
|
||||
.rdp-day_outside {
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.rdp-day[disabled] {
|
||||
color: ${s("textTertiary")};
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The selected day is a solid accent-filled circle. */
|
||||
.rdp-day_selected,
|
||||
.rdp-day_selected:hover,
|
||||
.rdp-day_selected:focus-visible {
|
||||
background: ${s("accent")};
|
||||
color: ${s("accentText")};
|
||||
font-weight: 500;
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useTheme } from "styled-components";
|
||||
|
||||
type Props = { day?: number; className?: string };
|
||||
|
||||
export function DynamicCalendarIcon({ day, className }: Props) {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
// Decorative icon: hide from assistive tech so the day digit isn't
|
||||
// announced out of context.
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
// Isolate so the day text only blends against the icon's own fill below
|
||||
// it, not whatever is behind the icon on the page.
|
||||
style={{ isolation: "isolate" }}
|
||||
>
|
||||
<path
|
||||
d="M10 5.01953C10.3319 5.00624 10.6846 5 11.0596 5H12.9404C13.3154 5 13.6681 5.00624 14 5.01953V4H16V5.24609C18.3996 5.78241 19 7.32118 19 11.0596V12.9404C19 17.9302 17.9302 19 12.9404 19H11.0596C6.06982 19 5 17.9302 5 12.9404V11.0596C5 7.32118 5.60035 5.78241 8 5.24609V4H10V5.01953Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<text
|
||||
// White blended with "difference" against the fill below produces the
|
||||
// exact inverse of the fill colour, so the day is always legible
|
||||
// regardless of the icon's (currentColor) fill.
|
||||
fill="white"
|
||||
style={{ mixBlendMode: "difference" }}
|
||||
fontFamily={theme.fontFamily}
|
||||
fontSize="8"
|
||||
fontWeight="600"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
letterSpacing="0em"
|
||||
>
|
||||
<tspan x="12" y="13.5">
|
||||
{day}
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { Node } from "prosemirror-model";
|
||||
import type { Command } from "prosemirror-state";
|
||||
import {
|
||||
createEditorStateWithSelection,
|
||||
doc,
|
||||
p,
|
||||
schema,
|
||||
} from "@shared/test/editor";
|
||||
import toggleList from "./toggleList";
|
||||
|
||||
const { bullet_list, ordered_list, list_item } = schema.nodes;
|
||||
|
||||
/**
|
||||
* Creates a list item node with the given block content.
|
||||
*/
|
||||
function li(content: Node[]) {
|
||||
return list_item.create(null, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a position inside the first text node matching the given text.
|
||||
*
|
||||
* @throws if no matching text node exists in the document.
|
||||
*/
|
||||
function posOfText(node: Node, text: string) {
|
||||
let found = -1;
|
||||
node.descendants((child, pos) => {
|
||||
if (found === -1 && child.isText && child.text === text) {
|
||||
found = pos;
|
||||
}
|
||||
return found === -1;
|
||||
});
|
||||
if (found === -1) {
|
||||
throw new Error(`Text "${text}" not found in document`);
|
||||
}
|
||||
return found + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command with the selection placed inside the given text and returns
|
||||
* the resulting document.
|
||||
*/
|
||||
function run(testDoc: Node, selectionText: string, command: Command) {
|
||||
let state = createEditorStateWithSelection(
|
||||
testDoc,
|
||||
posOfText(testDoc, selectionText)
|
||||
);
|
||||
command(state, (tr) => {
|
||||
state = state.apply(tr);
|
||||
});
|
||||
return state.doc;
|
||||
}
|
||||
|
||||
describe("toggleList", () => {
|
||||
it("converts a nested ordered list to bullet without changing the parent list", () => {
|
||||
const testDoc = doc([
|
||||
ordered_list.create(null, [
|
||||
li([p("one")]),
|
||||
li([p("two"), ordered_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "nested", toggleList(bullet_list, list_item));
|
||||
|
||||
const outer = result.firstChild;
|
||||
expect(outer?.type.name).toBe("ordered_list");
|
||||
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
|
||||
});
|
||||
|
||||
it("converts a nested bullet list to ordered without changing the parent list", () => {
|
||||
const testDoc = doc([
|
||||
bullet_list.create(null, [
|
||||
li([p("one")]),
|
||||
li([p("two"), bullet_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "nested", toggleList(ordered_list, list_item));
|
||||
|
||||
const outer = result.firstChild;
|
||||
expect(outer?.type.name).toBe("bullet_list");
|
||||
expect(outer?.child(1).child(1).type.name).toBe("ordered_list");
|
||||
});
|
||||
|
||||
it("converts the list and its children when the selection is in the parent list", () => {
|
||||
const testDoc = doc([
|
||||
ordered_list.create(null, [
|
||||
li([p("one")]),
|
||||
li([p("two"), ordered_list.create(null, [li([p("nested")])])]),
|
||||
]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
|
||||
|
||||
const outer = result.firstChild;
|
||||
expect(outer?.type.name).toBe("bullet_list");
|
||||
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
|
||||
});
|
||||
|
||||
it("lifts the item out of the list when toggling the same list type", () => {
|
||||
const testDoc = doc([
|
||||
bullet_list.create(null, [li([p("one")]), li([p("two")])]),
|
||||
]);
|
||||
|
||||
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
|
||||
|
||||
expect(result.childCount).toBe(2);
|
||||
expect(result.child(0).type.name).toBe("bullet_list");
|
||||
expect(result.child(1).type.name).toBe("paragraph");
|
||||
expect(result.child(1).textContent).toBe("two");
|
||||
});
|
||||
});
|
||||
@@ -54,7 +54,14 @@ export default function toggleList(
|
||||
parentList.pos,
|
||||
parentList.pos + parentList.node.nodeSize,
|
||||
(node, pos) => {
|
||||
if (isList(node, schema)) {
|
||||
// nodesBetween also visits the ancestors of the given range, these
|
||||
// must be skipped so that toggling a nested list does not convert
|
||||
// the lists it is nested within.
|
||||
if (
|
||||
pos >= parentList.pos &&
|
||||
isList(node, schema) &&
|
||||
listType.validContent(node.content)
|
||||
) {
|
||||
tr.setNodeMarkup(pos, listType, listStyle ? { listStyle } : {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemoveScroll } from "react-remove-scroll";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "../../components/Calendar";
|
||||
import { depths, s } from "../../styles";
|
||||
import { dateLocale, toISODate } from "../../utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The currently selected date, if any. */
|
||||
selectedDate?: Date;
|
||||
/** The user's language, used to localise the calendar. */
|
||||
language?: Parameters<typeof dateLocale>[0];
|
||||
/** Called with the new date-only ISO string when a day is picked. */
|
||||
onChange: (modelId: string) => void;
|
||||
/** The trigger element the calendar popover is anchored to. */
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The interactive calendar popover for a date mention. It lives in its own
|
||||
* module so that its browser-only dependencies (Radix, react-day-picker) are
|
||||
* loaded lazily and stay out of the editor schema graph, which is also imported
|
||||
* on the server.
|
||||
*
|
||||
* @returns the popover wrapping the provided trigger.
|
||||
*/
|
||||
export default function DateMentionPicker({
|
||||
selectedDate,
|
||||
language,
|
||||
onChange,
|
||||
children,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
onChange(toISODate(date));
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
aria-label={t("Choose a date")}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<RemoveScroll as={Slot} allowPinchZoom>
|
||||
<DatePopoverContent>
|
||||
<Calendar
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
defaultMonth={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
locale={dateLocale(language)}
|
||||
/>
|
||||
</DatePopoverContent>
|
||||
</RemoveScroll>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const DatePopoverContent = styled.div`
|
||||
z-index: ${depths.modal};
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { dateToRelativeReadable, parseISODate } from "../../utils/date";
|
||||
import { Backticks } from "../../components/Backticks";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
@@ -510,6 +511,55 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
type DateProps = ComponentProps & {
|
||||
onChangeDate: (modelId: string) => void;
|
||||
};
|
||||
|
||||
// Loaded lazily so its browser-only dependencies (Radix, react-day-picker)
|
||||
// don't enter the editor schema's static import graph, which is also used on
|
||||
// the server.
|
||||
const DateMentionPicker = React.lazy(() => import("./DateMentionPicker"));
|
||||
|
||||
export const MentionDate = observer(function MentionDate_(props: DateProps) {
|
||||
const { isSelected, isEditable, node, onChangeDate } = props;
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useStores();
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
const language = auth.user?.language;
|
||||
const iso = typeof node.attrs.modelId === "string" ? node.attrs.modelId : "";
|
||||
const display = dateToRelativeReadable(iso, t, language);
|
||||
const selectedDate = parseISODate(iso) ?? undefined;
|
||||
|
||||
const content = (
|
||||
<DateMention
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
$editable={isEditable}
|
||||
>
|
||||
{display}
|
||||
</DateMention>
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={content}>
|
||||
<DateMentionPicker
|
||||
selectedDate={selectedDate}
|
||||
language={language}
|
||||
onChange={onChangeDate}
|
||||
>
|
||||
{content}
|
||||
</DateMentionPicker>
|
||||
</React.Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -532,6 +582,11 @@ const MentionError = ({ className }: { className: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DateMention = styled.span<{ $editable: boolean }>`
|
||||
cursor: ${(props) => (props.$editable ? "pointer" : "default")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -570,6 +570,12 @@ width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* Date mentions are plain text, so they inherit the surrounding font weight
|
||||
(e.g. bold when placed inside a heading). */
|
||||
&[data-type="date"] {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
&.mention-user::before {
|
||||
content: "@";
|
||||
}
|
||||
@@ -596,7 +602,7 @@ width: 100%;
|
||||
padding: ${props.editorStyle?.padding ?? "initial"};
|
||||
margin: ${props.editorStyle?.margin ?? "initial"};
|
||||
|
||||
& > .ProseMirror-yjs-cursor {
|
||||
& > .${EditorStyleHelper.multiplayerCursor} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -670,11 +676,11 @@ width: 100%;
|
||||
h5 { font-size: var(--font-size-h5); }
|
||||
h6 { font-size: var(--font-size-h6); }
|
||||
|
||||
.ProseMirror-yjs-selection {
|
||||
.${EditorStyleHelper.multiplayerSelection} {
|
||||
transition: background-color 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.ProseMirror-yjs-cursor {
|
||||
.${EditorStyleHelper.multiplayerCursor} {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
@@ -682,6 +688,7 @@ width: 100%;
|
||||
border-right: 1px solid black;
|
||||
height: 1em;
|
||||
word-break: normal;
|
||||
user-select: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
@@ -719,7 +726,7 @@ width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.show-cursor-names .ProseMirror-yjs-cursor > div {
|
||||
&.show-cursor-names .${EditorStyleHelper.multiplayerCursor} > div {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -998,7 +1005,7 @@ img.ProseMirror-separator {
|
||||
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child,
|
||||
// Edge case where multiplayer cursor is between start of cell and heading
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child + .ProseMirror-yjs-cursor,
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child + .${EditorStyleHelper.multiplayerCursor},
|
||||
// Edge case where table grips are between start of cell and heading
|
||||
.${EditorStyleHelper.headingPositionAnchor}:first-child + [role=button] + [role=button] {
|
||||
& + h1,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getNameFromEmoji, getEmojiFromName, loadEmojiData } from "./emoji";
|
||||
import type { ProsemirrorData } from "../../types";
|
||||
import {
|
||||
getNameFromEmoji,
|
||||
getEmojiFromName,
|
||||
loadEmojiData,
|
||||
parseReactionShorthand,
|
||||
} from "./emoji";
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadEmojiData();
|
||||
@@ -15,3 +21,91 @@ describe("getEmojiFromName", () => {
|
||||
expect(getEmojiFromName("thinking_face")).toBe("🤔");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReactionShorthand", () => {
|
||||
const doc = (content: ProsemirrorData[]): ProsemirrorData => ({
|
||||
type: "doc",
|
||||
content,
|
||||
});
|
||||
|
||||
const paragraph = (content: ProsemirrorData[]): ProsemirrorData => ({
|
||||
type: "paragraph",
|
||||
content,
|
||||
});
|
||||
|
||||
const text = (value: string): ProsemirrorData => ({
|
||||
type: "text",
|
||||
text: value,
|
||||
});
|
||||
|
||||
const emoji = (name: string): ProsemirrorData => ({
|
||||
type: "emoji",
|
||||
attrs: { "data-name": name },
|
||||
});
|
||||
|
||||
it("resolves a '+' followed by an emoji node", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("+"), emoji("thumbs_up")])]))
|
||||
).toBe("👍");
|
||||
});
|
||||
|
||||
it("ignores whitespace between the '+' and the emoji node", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([paragraph([text("+"), text(" "), emoji("thinking_face")])])
|
||||
)
|
||||
).toBe("🤔");
|
||||
});
|
||||
|
||||
it("resolves a custom emoji UUID to its UUID", () => {
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("+"), emoji(uuid)])]))
|
||||
).toBe(uuid);
|
||||
});
|
||||
|
||||
it("resolves literal '+:shortcode:' text", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("+:thinking_face:")])]))
|
||||
).toBe("🤔");
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown shortcode", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([paragraph([text("+"), emoji("not_an_emoji")])])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there is text alongside the emoji", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([paragraph([text("+ nice "), emoji("thumbs_up")])])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a regular comment", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([text("Looks good to me")])]))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when the '+' prefix is missing", () => {
|
||||
expect(
|
||||
parseReactionShorthand(doc([paragraph([emoji("thumbs_up")])]))
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for multiple paragraphs", () => {
|
||||
expect(
|
||||
parseReactionShorthand(
|
||||
doc([
|
||||
paragraph([text("+"), emoji("thumbs_up")]),
|
||||
paragraph([text("more")]),
|
||||
])
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { EmojiMartData } from "@emoji-mart/data";
|
||||
import { isUUID } from "validator";
|
||||
import type { ProsemirrorData } from "../../types";
|
||||
|
||||
export const emojiMartToGemoji: Record<string, string> = {
|
||||
"+1": "thumbs_up",
|
||||
@@ -74,3 +76,77 @@ export const getEmojiFromName = (name: string) =>
|
||||
*/
|
||||
export const getNameFromEmoji = (emoji: string) =>
|
||||
Object.entries(nameToEmoji).find(([, value]) => value === emoji)?.[0];
|
||||
|
||||
/**
|
||||
* Resolve an emoji node name to the value used to react with.
|
||||
*
|
||||
* @param name The emoji shortcode, or a UUID for a custom emoji.
|
||||
* @returns the native emoji character, the UUID of a custom emoji, or undefined
|
||||
* when the name does not resolve to a known emoji.
|
||||
*/
|
||||
function getReactionFromName(name: unknown): string | undefined {
|
||||
if (typeof name !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Custom emojis are stored as UUIDs and reacted with directly.
|
||||
if (isUUID(name)) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const character = getEmojiFromName(name);
|
||||
return character === "?" ? undefined : character;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the "+:emoji:" reaction shorthand within a comment's document. When a
|
||||
* comment consists solely of a leading "+" immediately followed by a single
|
||||
* emoji it is treated as a request to react to the comment above rather than as
|
||||
* a new comment, mirroring the Slack shorthand.
|
||||
*
|
||||
* @param data The Prosemirror document of the draft comment.
|
||||
* @returns the emoji to react with — a native emoji character, or a UUID for a
|
||||
* custom emoji — or undefined when the document is not a reaction shorthand.
|
||||
*/
|
||||
export function parseReactionShorthand(
|
||||
data: ProsemirrorData
|
||||
): string | undefined {
|
||||
const blocks = data.content ?? [];
|
||||
if (blocks.length !== 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const paragraph = blocks[0];
|
||||
if (paragraph.type !== "paragraph") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Ignore whitespace-only text nodes so that "+ :emoji:" still matches.
|
||||
const inline = (paragraph.content ?? []).filter(
|
||||
(node) => !(node.type === "text" && !node.text?.trim())
|
||||
);
|
||||
|
||||
// The common case: a "+" text node followed by an emoji node inserted via
|
||||
// the emoji menu.
|
||||
if (inline.length === 2) {
|
||||
const [prefix, emoji] = inline;
|
||||
if (
|
||||
prefix.type === "text" &&
|
||||
prefix.text?.trim() === "+" &&
|
||||
emoji.type === "emoji"
|
||||
) {
|
||||
return getReactionFromName(emoji.attrs?.["data-name"]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Fallback: literal "+:shortcode:" text that was never converted to a node.
|
||||
if (inline.length === 1 && inline[0].type === "text") {
|
||||
const match = inline[0].text?.trim().match(/^\+\s*:([\w-]+):$/);
|
||||
if (match) {
|
||||
return getReactionFromName(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ export class MarkdownSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks whether we have already warned about direct assignment to `out`,
|
||||
// so a hot loop cannot flood the console.
|
||||
let warnedDirectOutAssignment = false;
|
||||
|
||||
export interface BlockMapEntry {
|
||||
/** Start position in the ProseMirror document (offset within parent content). */
|
||||
pmFrom: number;
|
||||
@@ -103,14 +107,36 @@ export class MarkdownSerializerState {
|
||||
inTightList = false;
|
||||
closed = false;
|
||||
delim = "";
|
||||
out = "";
|
||||
_out = "";
|
||||
lastChar = "";
|
||||
options: Options;
|
||||
blockMap = null;
|
||||
|
||||
// The serialized output so far. Use `append` to add to it — direct
|
||||
// assignment still works but reads the last character back out of the
|
||||
// string, which forces V8 to flatten the internal rope and is slow when
|
||||
// done repeatedly on large documents.
|
||||
get out() {
|
||||
return this._out;
|
||||
}
|
||||
|
||||
set out(value) {
|
||||
if (!warnedDirectOutAssignment) {
|
||||
warnedDirectOutAssignment = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"MarkdownSerializerState: assigning `out` directly is slow on large documents, use append() instead."
|
||||
);
|
||||
}
|
||||
this._out = value;
|
||||
this.lastChar = value === "" ? "" : value.charAt(value.length - 1);
|
||||
}
|
||||
|
||||
constructor(nodes, marks, options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.delim = this.out = "";
|
||||
this.delim = this._out = "";
|
||||
this.lastChar = "";
|
||||
this.closed = false;
|
||||
this.inTightList = false;
|
||||
this.inTable = false;
|
||||
@@ -126,10 +152,21 @@ export class MarkdownSerializerState {
|
||||
}
|
||||
}
|
||||
|
||||
// :: (string)
|
||||
// Append a string to the output, tracking `lastChar` without reading
|
||||
// characters back out of `out` — that would force V8 to flatten the
|
||||
// internal rope, which is quadratic on large documents.
|
||||
append(content) {
|
||||
if (content) {
|
||||
this._out += content;
|
||||
this.lastChar = content.charAt(content.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
flushClose(size) {
|
||||
if (this.closed) {
|
||||
if (!this.atBlank()) {
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
}
|
||||
if (size === null || size === undefined) {
|
||||
size = 2;
|
||||
@@ -141,7 +178,7 @@ export class MarkdownSerializerState {
|
||||
delimMin = delimMin.slice(0, delimMin.length - trim[0].length);
|
||||
}
|
||||
for (let i = 1; i < size; i++) {
|
||||
this.out += delimMin + "\n";
|
||||
this.append(delimMin + "\n");
|
||||
}
|
||||
}
|
||||
this.closed = false;
|
||||
@@ -163,14 +200,14 @@ export class MarkdownSerializerState {
|
||||
}
|
||||
|
||||
atBlank() {
|
||||
return /(^|\n)$/.test(this.out);
|
||||
return this.lastChar === "" || this.lastChar === "\n";
|
||||
}
|
||||
|
||||
// :: ()
|
||||
// Ensure the current content ends with a newline.
|
||||
ensureNewLine() {
|
||||
if (!this.atBlank()) {
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,10 +218,10 @@ export class MarkdownSerializerState {
|
||||
write(content) {
|
||||
this.flushClose();
|
||||
if (this.delim && this.atBlank()) {
|
||||
this.out += this.delim;
|
||||
this.append(this.delim);
|
||||
}
|
||||
if (content) {
|
||||
this.out += content;
|
||||
this.append(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +239,11 @@ export class MarkdownSerializerState {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const startOfLine = this.atBlank() || this.closed;
|
||||
this.write();
|
||||
this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i];
|
||||
this.append(
|
||||
escape !== false ? this.esc(lines[i], startOfLine) : lines[i]
|
||||
);
|
||||
if (i !== lines.length - 1) {
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -389,9 +428,9 @@ export class MarkdownSerializerState {
|
||||
if (this.inTable) {
|
||||
node.forEach((child, _, i) => {
|
||||
if (i > 0) {
|
||||
this.out += " <br> ";
|
||||
this.append(" <br> ");
|
||||
}
|
||||
this.out += firstDelim(i).trim() + " ";
|
||||
this.append(firstDelim(i).trim() + " ");
|
||||
this.render(child, node, i);
|
||||
});
|
||||
return;
|
||||
@@ -438,12 +477,12 @@ export class MarkdownSerializerState {
|
||||
});
|
||||
|
||||
// Ensure there is an empty newline above all tables
|
||||
this.out += "\n";
|
||||
this.append("\n");
|
||||
|
||||
// Render rows
|
||||
node.forEach((row, _, i) => {
|
||||
row.forEach((cell, _, j) => {
|
||||
this.out += j === 0 ? "| " : " | ";
|
||||
this.append(j === 0 ? "| " : " | ");
|
||||
|
||||
const startPos = this.out.length;
|
||||
|
||||
@@ -463,26 +502,26 @@ export class MarkdownSerializerState {
|
||||
// Pad to column width
|
||||
const contentLength = this.out.length - startPos;
|
||||
const padding = Math.max(0, columnWidths[j] - contentLength);
|
||||
this.out += " ".repeat(padding);
|
||||
this.append(" ".repeat(padding));
|
||||
});
|
||||
|
||||
this.out += " |\n";
|
||||
this.append(" |\n");
|
||||
|
||||
// Header separator after first row
|
||||
if (i === 0) {
|
||||
headerRow.forEach((cell, _, j) => {
|
||||
const width = columnWidths[j];
|
||||
if (cell.attrs.alignment === "center") {
|
||||
this.out += "|:" + "-".repeat(width) + ":";
|
||||
this.append("|:" + "-".repeat(width) + ":");
|
||||
} else if (cell.attrs.alignment === "left") {
|
||||
this.out += "|:" + "-".repeat(width + 1);
|
||||
this.append("|:" + "-".repeat(width + 1));
|
||||
} else if (cell.attrs.alignment === "right") {
|
||||
this.out += "|" + "-".repeat(width + 1) + ":";
|
||||
this.append("|" + "-".repeat(width + 1) + ":");
|
||||
} else {
|
||||
this.out += "|" + "-".repeat(width + 2);
|
||||
this.append("|" + "-".repeat(width + 2));
|
||||
}
|
||||
});
|
||||
this.out += "|\n";
|
||||
this.append("|\n");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@ export default class CheckboxItem extends Node {
|
||||
}
|
||||
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
state.out += node.attrs.checked ? "[x] " : "[ ] ";
|
||||
state.append(node.attrs.checked ? "[x] " : "[ ] ");
|
||||
if (state.inTable) {
|
||||
node.forEach((block, _, i) => {
|
||||
if (i > 0) {
|
||||
state.out += " ";
|
||||
state.append(" ");
|
||||
}
|
||||
state.renderInline(block);
|
||||
});
|
||||
|
||||
@@ -52,8 +52,8 @@ const parseTitleAttribute = (tokenTitle: string): TitleAttributes => {
|
||||
|
||||
const match = tokenTitle.match(imageSizeRegex);
|
||||
if (match) {
|
||||
attributes.width = parseInt(match[1], 10);
|
||||
attributes.height = parseInt(match[2], 10);
|
||||
attributes.width = match[1] ? parseInt(match[1], 10) : undefined;
|
||||
attributes.height = match[2] ? parseInt(match[2], 10) : undefined;
|
||||
tokenTitle = tokenTitle.replace(imageSizeRegex, "");
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ export default class ListItem extends Node {
|
||||
if (state.inTable) {
|
||||
node.forEach((block, _, i) => {
|
||||
if (i > 0) {
|
||||
state.out += " ";
|
||||
state.append(" ");
|
||||
}
|
||||
state.renderInline(block);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import type { UnfurlResponse } from "../../types";
|
||||
import { MentionType, UnfurlResourceType } from "../../types";
|
||||
import { dateToReadable } from "../../utils/date";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
MentionIssue,
|
||||
MentionProject,
|
||||
MentionPullRequest,
|
||||
MentionDate,
|
||||
MentionURL,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
@@ -39,17 +41,25 @@ export default class Mention extends Node {
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
const toPlainText = (node: ProsemirrorNode) =>
|
||||
node.attrs.type === MentionType.User
|
||||
// Date mentions derive their text from the ISO `modelId`, which is the
|
||||
// single source of truth — no human-readable label is persisted for them.
|
||||
const toPlainText = (node: ProsemirrorNode) => {
|
||||
if (node.attrs.type === MentionType.Date) {
|
||||
return dateToReadable(node.attrs.modelId);
|
||||
}
|
||||
return node.attrs.type === MentionType.User
|
||||
? `@${node.attrs.label}`
|
||||
: node.attrs.label;
|
||||
};
|
||||
|
||||
return {
|
||||
attrs: {
|
||||
type: {
|
||||
default: MentionType.User,
|
||||
},
|
||||
label: {},
|
||||
label: {
|
||||
default: undefined,
|
||||
},
|
||||
modelId: {},
|
||||
actorId: {
|
||||
default: undefined,
|
||||
@@ -84,7 +94,9 @@ export default class Mention extends Node {
|
||||
type,
|
||||
modelId,
|
||||
actorId: dom.dataset.actorid,
|
||||
label: dom.innerText,
|
||||
// Date mentions derive their text from `modelId`; never capture
|
||||
// the rendered text as a persisted label.
|
||||
label: type === MentionType.Date ? undefined : dom.innerText,
|
||||
id: dom.id,
|
||||
href: dom.getAttribute("href"),
|
||||
unfurl: dom.dataset.unfurl
|
||||
@@ -95,12 +107,21 @@ export default class Mention extends Node {
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
node.attrs.type === MentionType.User ? "span" : "a",
|
||||
node.attrs.type === MentionType.User ||
|
||||
node.attrs.type === MentionType.Date
|
||||
? "span"
|
||||
: "a",
|
||||
{
|
||||
class: `${node.type.name} use-hover-preview`,
|
||||
// Date mentions are self-contained and have nothing to unfurl, so
|
||||
// they opt out of the hover preview behaviour.
|
||||
class:
|
||||
node.attrs.type === MentionType.Date
|
||||
? node.type.name
|
||||
: `${node.type.name} use-hover-preview`,
|
||||
id: node.attrs.id,
|
||||
href:
|
||||
node.attrs.type === MentionType.User
|
||||
node.attrs.type === MentionType.User ||
|
||||
node.attrs.type === MentionType.Date
|
||||
? undefined
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
@@ -162,6 +183,10 @@ export default class Mention extends Node {
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionType.Date:
|
||||
return (
|
||||
<MentionDate {...props} onChangeDate={this.handleChangeDate(props)} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -315,7 +340,10 @@ export default class Mention extends Node {
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
const mType = node.attrs.type;
|
||||
const mId = node.attrs.modelId;
|
||||
const label = node.attrs.label;
|
||||
// Date mentions have no stored label; the readable text is derived from
|
||||
// the ISO `modelId` so it can never drift from the source of truth.
|
||||
const label =
|
||||
mType === MentionType.Date ? dateToReadable(mId) : node.attrs.label;
|
||||
const id = node.attrs.id;
|
||||
|
||||
// Use regular links for document and collection mentions
|
||||
@@ -336,11 +364,32 @@ export default class Mention extends Node {
|
||||
id: tok.attrGet("id"),
|
||||
type: tok.attrGet("type"),
|
||||
modelId: tok.attrGet("modelId"),
|
||||
label: tok.content,
|
||||
// Date mentions derive their text from `modelId`; the link text is not
|
||||
// persisted as a label.
|
||||
label:
|
||||
tok.attrGet("type") === MentionType.Date ? undefined : tok.content,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeDate =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(modelId: string) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
const pos = getPos();
|
||||
|
||||
if (node.attrs.modelId === modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
modelId,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleChangeUnfurl =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
|
||||
|
||||
@@ -17,6 +17,11 @@ import attachmentsRule from "../rules/links";
|
||||
import type { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
const parseDimension = (value: string | null): number | null => {
|
||||
const parsed = parseInt(value ?? "", 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export default class Video extends Node {
|
||||
get name() {
|
||||
return "video";
|
||||
@@ -56,12 +61,12 @@ export default class Video extends Node {
|
||||
{
|
||||
priority: 100,
|
||||
tag: "video",
|
||||
getAttrs: (dom: HTMLAnchorElement) => ({
|
||||
getAttrs: (dom: HTMLVideoElement) => ({
|
||||
id: dom.id,
|
||||
title: dom.getAttribute("title"),
|
||||
src: dom.getAttribute("src"),
|
||||
width: parseInt(dom.getAttribute("width") ?? "", 10),
|
||||
height: parseInt(dom.getAttribute("height") ?? "", 10),
|
||||
width: parseDimension(dom.getAttribute("width")),
|
||||
height: parseDimension(dom.getAttribute("height")),
|
||||
}),
|
||||
},
|
||||
],
|
||||
@@ -184,7 +189,9 @@ export default class Video extends Node {
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
state.ensureNewLine();
|
||||
state.write(
|
||||
`[${node.attrs.title} ${node.attrs.width}x${node.attrs.height}](${node.attrs.src})\n\n`
|
||||
`[${node.attrs.title} ${node.attrs.width ?? ""}x${
|
||||
node.attrs.height ?? ""
|
||||
}](${node.attrs.src})\n\n`
|
||||
);
|
||||
state.ensureNewLine();
|
||||
}
|
||||
@@ -195,8 +202,8 @@ export default class Video extends Node {
|
||||
getAttrs: (tok: Token) => ({
|
||||
src: tok.attrGet("src"),
|
||||
title: tok.attrGet("title"),
|
||||
width: parseInt(tok.attrGet("width") ?? "", 10),
|
||||
height: parseInt(tok.attrGet("height") ?? "", 10),
|
||||
width: parseDimension(tok.attrGet("width")),
|
||||
height: parseDimension(tok.attrGet("height")),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { extensionManager, schema } from "../../test/editor";
|
||||
import { extensionManager, findNodes, schema } from "../../test/editor";
|
||||
|
||||
const serializer = extensionManager.serializer();
|
||||
const parser = extensionManager.parser({
|
||||
@@ -6,29 +6,19 @@ const parser = extensionManager.parser({
|
||||
plugins: extensionManager.rulePlugins,
|
||||
});
|
||||
|
||||
interface ProsemirrorNode {
|
||||
type: string;
|
||||
content?: ProsemirrorNode[];
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
it("preserves mixed checkbox and regular items in a list", () => {
|
||||
const markdown = `- [x] Checked item
|
||||
- Regular item
|
||||
- [ ] Unchecked item`;
|
||||
|
||||
const ast = parser.parse(markdown);
|
||||
const json = ast?.toJSON();
|
||||
|
||||
const checkboxList = json?.content?.find(
|
||||
(node: ProsemirrorNode) => node.type === "checkbox_list"
|
||||
);
|
||||
const [checkboxList] = findNodes(ast?.toJSON(), "checkbox_list");
|
||||
|
||||
expect(checkboxList).toBeDefined();
|
||||
expect(checkboxList?.content).toHaveLength(3);
|
||||
expect(checkboxList?.content[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content[1].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content[2].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[1].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[2].type).toBe("checkbox_item");
|
||||
});
|
||||
|
||||
it("round-trips mixed checkbox lists through serializer", () => {
|
||||
@@ -52,22 +42,15 @@ it("does not convert nested bullet list items inside checkbox lists", () => {
|
||||
- [ ] Second checkbox`;
|
||||
|
||||
const ast = parser.parse(markdown);
|
||||
const json = ast?.toJSON();
|
||||
|
||||
const checkboxList = json?.content?.find(
|
||||
(node: ProsemirrorNode) => node.type === "checkbox_list"
|
||||
);
|
||||
const [checkboxList] = findNodes(ast?.toJSON(), "checkbox_list");
|
||||
|
||||
expect(checkboxList).toBeDefined();
|
||||
expect(checkboxList?.content).toHaveLength(2);
|
||||
expect(checkboxList?.content[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content[1].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[0].type).toBe("checkbox_item");
|
||||
expect(checkboxList?.content?.[1].type).toBe("checkbox_item");
|
||||
|
||||
// Nested list should remain a bullet_list, not a checkbox_list
|
||||
const nestedContent = checkboxList?.content[0].content;
|
||||
const nestedList = nestedContent?.find(
|
||||
(node: ProsemirrorNode) => node.type === "bullet_list"
|
||||
);
|
||||
const [nestedList] = findNodes(checkboxList?.content?.[0], "bullet_list");
|
||||
expect(nestedList).toBeDefined();
|
||||
expect(nestedList?.content?.[0].type).toBe("list_item");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { JSONNode } from "../../test/editor";
|
||||
import { extensionManager, findNodes, schema } from "../../test/editor";
|
||||
|
||||
const parser = extensionManager.parser({
|
||||
schema,
|
||||
plugins: extensionManager.rulePlugins,
|
||||
});
|
||||
|
||||
const parseToJSON = (markdown: string): JSONNode | undefined =>
|
||||
parser.parse(markdown)?.toJSON();
|
||||
|
||||
describe("math markdown rules", () => {
|
||||
it("parses inline math", () => {
|
||||
const doc = parseToJSON("before $x + y$ after");
|
||||
const nodes = findNodes(doc, "math_inline");
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].content?.[0].text).toBe("x + y");
|
||||
});
|
||||
|
||||
it("parses block math with closing delimiter on its own line", () => {
|
||||
const doc = parseToJSON("$$\na = b\n$$\n\nparagraph after");
|
||||
const nodes = findNodes(doc, "math_block");
|
||||
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect(nodes[0].content?.[0].text).toContain("a = b");
|
||||
expect(findNodes(doc, "paragraph")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses block math with closing delimiter at the end of a content line", () => {
|
||||
const doc = parseToJSON("$$\na = b\nc = d$$\n\nparagraph after");
|
||||
const blocks = findNodes(doc, "math_block");
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].content?.[0].text).toContain("a = b");
|
||||
expect(blocks[0].content?.[0].text).toContain("c = d");
|
||||
|
||||
// The paragraph following the block must not be swallowed into the math
|
||||
const paragraphs = findNodes(doc, "paragraph");
|
||||
expect(paragraphs).toHaveLength(1);
|
||||
expect(blocks[0].content?.[0].text).not.toContain("paragraph after");
|
||||
});
|
||||
|
||||
it("leaves unclosed inline math as plain text", () => {
|
||||
const doc = parseToJSON("price is $5 and rising");
|
||||
|
||||
expect(findNodes(doc, "math_inline")).toHaveLength(0);
|
||||
expect(findNodes(doc, "math_block")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ function mathInline(state: StateInline, silent: boolean): boolean {
|
||||
// we have found an opening delimiter already
|
||||
const start = state.pos + inlineMathDelimiter.length;
|
||||
match = start;
|
||||
while ((match = state.src.indexOf(inlineMathDelimiter, match)) !== 1) {
|
||||
while ((match = state.src.indexOf(inlineMathDelimiter, match)) !== -1) {
|
||||
// found potential delimeter, look for escapes, pos will point to
|
||||
// first non escape when complete
|
||||
pos = match - 1;
|
||||
@@ -166,7 +166,10 @@ function mathDisplay(
|
||||
break;
|
||||
}
|
||||
|
||||
if (state.src.slice(pos, max).trim().slice(-3) === blockMathDelimiter) {
|
||||
if (
|
||||
state.src.slice(pos, max).trim().slice(-blockMathDelimiter.length) ===
|
||||
blockMathDelimiter
|
||||
) {
|
||||
lastPos = state.src.slice(0, max).lastIndexOf(blockMathDelimiter);
|
||||
lastLine = state.src.slice(pos, lastPos);
|
||||
found = true;
|
||||
|
||||
@@ -98,6 +98,21 @@ describe("mention rule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("date format", () => {
|
||||
it("should parse a date mention with an ISO date modelId", () => {
|
||||
const result = md.parse(
|
||||
"@[February 3rd, 2024](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03)",
|
||||
{}
|
||||
);
|
||||
const mentions = findMentionTokens(result);
|
||||
|
||||
expect(mentions).toHaveLength(1);
|
||||
expect(mentions[0].type).toBe("date");
|
||||
expect(mentions[0].modelId).toBe("2024-02-03");
|
||||
expect(mentions[0].label).toBe("February 3rd, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed content", () => {
|
||||
it("should parse mention within text", () => {
|
||||
const result = md.parse(
|
||||
|
||||
@@ -22,6 +22,14 @@ export class EditorStyleHelper {
|
||||
|
||||
static readonly comment = "comment-marker";
|
||||
|
||||
// Multiplayer
|
||||
|
||||
/** Remote collaborator's cursor */
|
||||
static readonly multiplayerCursor = "ProseMirror-yjs-cursor";
|
||||
|
||||
/** Remote collaborator's selection */
|
||||
static readonly multiplayerSelection = "ProseMirror-yjs-selection";
|
||||
|
||||
// Code
|
||||
|
||||
static readonly codeBlock = "code-block";
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
"Collection": "Collection",
|
||||
"Collections": "Collections",
|
||||
"Debug": "Debug",
|
||||
"Date": "Date",
|
||||
"Document": "Document",
|
||||
"Search results": "Search results",
|
||||
"Documents": "Documents",
|
||||
@@ -616,8 +617,6 @@
|
||||
"Divider": "Divider",
|
||||
"Page break": "Page break",
|
||||
"Current date": "Current date",
|
||||
"Current time": "Current time",
|
||||
"Current date and time": "Current date and time",
|
||||
"Info notice": "Info notice",
|
||||
"Success notice": "Success notice",
|
||||
"Warning notice": "Warning notice",
|
||||
@@ -1781,5 +1780,6 @@
|
||||
"Hide completed": "Hide completed",
|
||||
"Write a caption": "Write a caption",
|
||||
"Add title": "Add title",
|
||||
"Add content": "Add content"
|
||||
"Add content": "Add content",
|
||||
"Tomorrow": "Tomorrow"
|
||||
}
|
||||
|
||||
@@ -238,3 +238,35 @@ export function doc(
|
||||
) {
|
||||
return schema.nodes.doc.create(null, content);
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain-object representation of a ProseMirror node, as returned by
|
||||
* `Node.toJSON()`.
|
||||
*/
|
||||
export interface JSONNode {
|
||||
type: string;
|
||||
content?: JSONNode[];
|
||||
attrs?: Record<string, unknown>;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all nodes of the given type from a `Node.toJSON()`
|
||||
* tree, including the root node itself.
|
||||
*
|
||||
* @param node - the JSON node to search, may be undefined for convenience.
|
||||
* @param type - the node type name to match.
|
||||
* @returns array of matching nodes in document order.
|
||||
*/
|
||||
export function findNodes(
|
||||
node: JSONNode | undefined,
|
||||
type: string
|
||||
): JSONNode[] {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...(node.type === type ? [node] : []),
|
||||
...(node.content ?? []).flatMap((child) => findNodes(child, type)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ export enum MentionType {
|
||||
PullRequest = "pull_request",
|
||||
Project = "project",
|
||||
URL = "url",
|
||||
Date = "date",
|
||||
}
|
||||
|
||||
export type PublicEnv = {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "./date";
|
||||
|
||||
describe("toISODate / parseISODate", () => {
|
||||
it("round-trips a date through its ISO representation", () => {
|
||||
const date = new Date(2024, 1, 3); // Feb 3, 2024
|
||||
const iso = toISODate(date);
|
||||
expect(iso).toBe("2024-02-03");
|
||||
expect(parseISODate(iso)).toEqual(date);
|
||||
});
|
||||
|
||||
it("returns null for an invalid ISO string", () => {
|
||||
expect(parseISODate("not-a-date")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects strings carrying a time component", () => {
|
||||
expect(parseISODate("2024-02-03T10:00:00Z")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a date-only string to local midnight", () => {
|
||||
const date = parseISODate("2024-02-03");
|
||||
expect(date?.getHours()).toBe(0);
|
||||
expect(date?.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateToReadable", () => {
|
||||
it("includes the year outside the current year", () => {
|
||||
expect(dateToReadable("2020-02-03")).toBe("February 3rd, 2020");
|
||||
});
|
||||
|
||||
it("omits the year within the current year", () => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() === 0 ? 6 : 0);
|
||||
date.setDate(15);
|
||||
const result = dateToReadable(toISODate(date));
|
||||
expect(result).not.toContain(`${date.getFullYear()}`);
|
||||
});
|
||||
|
||||
it("returns the original string when invalid", () => {
|
||||
expect(dateToReadable("nonsense")).toBe("nonsense");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateToRelativeReadable", () => {
|
||||
const t = (key: string) => key;
|
||||
|
||||
it("returns Today for the current date", () => {
|
||||
const iso = toISODate(new Date());
|
||||
expect(dateToRelativeReadable(iso, t)).toBe("Today");
|
||||
});
|
||||
|
||||
it("returns Tomorrow for the next day", () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
expect(dateToRelativeReadable(toISODate(tomorrow), t)).toBe("Tomorrow");
|
||||
});
|
||||
|
||||
it("returns Yesterday for the previous day", () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
expect(dateToRelativeReadable(toISODate(yesterday), t)).toBe("Yesterday");
|
||||
});
|
||||
|
||||
it("omits the year within the current year", () => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() === 0 ? 6 : 0); // a different month, same year
|
||||
date.setDate(15);
|
||||
const result = dateToRelativeReadable(toISODate(date), t);
|
||||
expect(result).not.toContain(`${date.getFullYear()}`);
|
||||
});
|
||||
|
||||
it("includes the year for a date in a different year", () => {
|
||||
expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,13 @@
|
||||
import type { Locale } from "date-fns";
|
||||
import {
|
||||
addSeconds,
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
isSameYear,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isYesterday,
|
||||
parseISO,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
@@ -297,3 +303,94 @@ export function dateLocale(language: keyof typeof locales | undefined | null) {
|
||||
}
|
||||
|
||||
export { locales };
|
||||
|
||||
/**
|
||||
* Formats a Date into a date-only ISO string (yyyy-MM-dd) in the local
|
||||
* timezone. Used as the stored value for date mentions.
|
||||
*
|
||||
* @param date The date to format.
|
||||
* @returns the date-only ISO string.
|
||||
*/
|
||||
export function toISODate(date: Date): string {
|
||||
return format(date, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
|
||||
* Strings carrying a time component are rejected so the date-only contract
|
||||
* (and the day-granular comparisons that depend on it) cannot be violated.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @returns the parsed Date at local midnight, or null when the string is not a
|
||||
* valid date-only value.
|
||||
*/
|
||||
export function parseISODate(iso: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
||||
return null;
|
||||
}
|
||||
const date = parseISO(iso);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date mention's stored ISO value into an absolute, localized,
|
||||
* human-readable label. The year is omitted within the current year (e.g.
|
||||
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). Suitable
|
||||
* for plaintext and markdown serialization.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @param language The user's language preference.
|
||||
* @returns the absolute human-readable date, or the original string when invalid.
|
||||
*/
|
||||
export function dateToReadable(
|
||||
iso: string,
|
||||
language?: keyof typeof locales | null
|
||||
): string {
|
||||
const date = parseISODate(iso);
|
||||
if (!date) {
|
||||
return iso;
|
||||
}
|
||||
const locale = dateLocale(language);
|
||||
if (isSameYear(date, new Date())) {
|
||||
return format(date, "MMMM do", { locale });
|
||||
}
|
||||
return format(date, "MMMM do, yyyy", { locale });
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date mention's stored ISO value into a relative, localized,
|
||||
* human-readable label with increasing granularity. Returns "Today",
|
||||
* "Tomorrow" or "Yesterday" where applicable, "January 2nd" within the
|
||||
* current year, and "February 3rd, 2024" otherwise.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @param t The translation function.
|
||||
* @param language The user's language preference.
|
||||
* @returns the relative human-readable date, or the original string when invalid.
|
||||
*/
|
||||
export function dateToRelativeReadable(
|
||||
iso: string,
|
||||
t: (key: string) => string,
|
||||
language?: keyof typeof locales | null
|
||||
): string {
|
||||
const date = parseISODate(iso);
|
||||
if (!date) {
|
||||
return iso;
|
||||
}
|
||||
|
||||
if (isToday(date)) {
|
||||
return t("Today");
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return t("Tomorrow");
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return t("Yesterday");
|
||||
}
|
||||
|
||||
const locale = dateLocale(language);
|
||||
if (isSameYear(date, new Date())) {
|
||||
return format(date, "MMMM do", { locale });
|
||||
}
|
||||
return format(date, "MMMM do, yyyy", { locale });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { parseNaturalLanguageDate } from "./parseNaturalLanguageDate";
|
||||
|
||||
describe("parseNaturalLanguageDate", () => {
|
||||
const reference = new Date(2024, 0, 1); // Mon Jan 1, 2024
|
||||
|
||||
it("returns null for empty input", async () => {
|
||||
expect(await parseNaturalLanguageDate("", reference)).toBeNull();
|
||||
expect(await parseNaturalLanguageDate(" ", reference)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-date input", async () => {
|
||||
expect(await parseNaturalLanguageDate("hello world", reference)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses 'today'", async () => {
|
||||
const result = await parseNaturalLanguageDate("today", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 1));
|
||||
});
|
||||
|
||||
it("parses 'tomorrow'", async () => {
|
||||
const result = await parseNaturalLanguageDate("tomorrow", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 2));
|
||||
});
|
||||
|
||||
it("parses 'yesterday'", async () => {
|
||||
const result = await parseNaturalLanguageDate("yesterday", reference);
|
||||
expect(result).toEqual(new Date(2023, 11, 31));
|
||||
});
|
||||
|
||||
it("parses 'in 3 days'", async () => {
|
||||
const result = await parseNaturalLanguageDate("in 3 days", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 4));
|
||||
});
|
||||
|
||||
it("parses an explicit month and day", async () => {
|
||||
const result = await parseNaturalLanguageDate("February 3", reference);
|
||||
expect(result).toEqual(new Date(2024, 1, 3));
|
||||
});
|
||||
|
||||
it("normalizes the time component to local midnight", async () => {
|
||||
const result = await parseNaturalLanguageDate("tomorrow at 5pm", reference);
|
||||
expect(result?.getHours()).toBe(0);
|
||||
expect(result?.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Type-only import is fully erased at compile time, so it does not pull
|
||||
// chrono-node into the bundle.
|
||||
import type * as Chrono from "chrono-node";
|
||||
|
||||
/**
|
||||
* chrono-node is a sizeable dependency, so it is loaded lazily on first use
|
||||
* via a dynamic import. The bundler splits it into its own chunk that is only
|
||||
* fetched when a date actually needs to be parsed (i.e. when the user types in
|
||||
* the mention menu), keeping it out of the main bundle.
|
||||
*/
|
||||
let chronoPromise: Promise<typeof Chrono> | undefined;
|
||||
|
||||
function loadChrono(): Promise<typeof Chrono> {
|
||||
if (!chronoPromise) {
|
||||
chronoPromise = import("chrono-node").catch((err) => {
|
||||
// Don't cache a rejected import (e.g. a transient chunk-load failure),
|
||||
// otherwise every subsequent parse would reuse the failure. Clearing it
|
||||
// lets the next call retry.
|
||||
chronoPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return chronoPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a natural language string such as "tomorrow", "next friday",
|
||||
* "jan 2" or "in 3 days" into a calendar date.
|
||||
*
|
||||
* The time component is intentionally discarded as date mentions are
|
||||
* day-granular; only the year, month and day of the matched date are
|
||||
* returned. chrono-node is loaded asynchronously the first time this is
|
||||
* called.
|
||||
*
|
||||
* @param input the natural language string to parse.
|
||||
* @param referenceDate the date relative to which terms like "tomorrow"
|
||||
* are resolved, defaults to now.
|
||||
* @returns a promise resolving to the matched date with the time set to
|
||||
* local midnight, or null when no date could be confidently parsed.
|
||||
*/
|
||||
export async function parseNaturalLanguageDate(
|
||||
input: string,
|
||||
referenceDate: Date = new Date()
|
||||
): Promise<Date | null> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chrono = await loadChrono();
|
||||
const results = chrono.parse(trimmed, referenceDate, { forwardDate: true });
|
||||
const result = results[0];
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only accept matches that span (roughly) the whole input so that
|
||||
// unrelated text typed after "@" does not accidentally resolve to a date.
|
||||
if (result.text.trim().length < trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = result.start.date();
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
@@ -8682,6 +8682,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chrono-node@npm:^2.9.1":
|
||||
version: 2.9.1
|
||||
resolution: "chrono-node@npm:2.9.1"
|
||||
checksum: 10c0/2408e4a404ea3e7e8226daae75cdbc4621bc66065d596574440d0f81a7c456c166ca212c9e1c329e653c716300289498794fece2102f9365bfd33bd1a4b24473
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ci-info@npm:^3.7.0":
|
||||
version: 3.9.0
|
||||
resolution: "ci-info@npm:3.9.0"
|
||||
@@ -15178,6 +15185,7 @@ __metadata:
|
||||
babel-plugin-transform-typescript-metadata: "npm:^0.4.0"
|
||||
browserslist-to-esbuild: "npm:^1.2.0"
|
||||
bull: "npm:^4.16.5"
|
||||
chrono-node: "npm:^2.9.1"
|
||||
class-validator: "npm:^0.15.1"
|
||||
command-score: "npm:^0.1.2"
|
||||
compressorjs: "npm:^1.3.0"
|
||||
@@ -18097,9 +18105,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"shell-quote@npm:^1.8.1":
|
||||
version: 1.8.3
|
||||
resolution: "shell-quote@npm:1.8.3"
|
||||
checksum: 10c0/bee87c34e1e986cfb4c30846b8e6327d18874f10b535699866f368ade11ea4ee45433d97bf5eada22c4320c27df79c3a6a7eb1bf3ecfc47f2c997d9e5e2672fd
|
||||
version: 1.8.4
|
||||
resolution: "shell-quote@npm:1.8.4"
|
||||
checksum: 10c0/86c93678bc394cb81f5ddcdc87df9c95d279ef9652775cd1cd1eed361404169a8d8cbaacaeed232ab09919e36ee1e5363863570390d78571f8c22b7f6312fb40
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user