feat: add tiptap markdown support, add checklist support from trello (#245)
* feat: add tiptap markdown support, add checklist support from trello * feat(api): implement bulk creation of checklists and items
This commit is contained in:
@@ -65,6 +65,7 @@
|
|||||||
"superjson": "2.2.1",
|
"superjson": "2.2.1",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-markdown": "^0.8",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { Instance as TippyInstance } from "tippy.js";
|
|||||||
import { Button } from "@headlessui/react";
|
import { Button } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Mention from "@tiptap/extension-mention";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import {
|
import {
|
||||||
BubbleMenu,
|
BubbleMenu,
|
||||||
@@ -39,9 +40,10 @@ import {
|
|||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import Mention from "@tiptap/extension-mention";
|
import { Markdown } from "tiptap-markdown";
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { getAvatarUrl } from "~/utils/helpers";
|
import { getAvatarUrl } from "~/utils/helpers";
|
||||||
|
import Avatar from "./Avatar";
|
||||||
|
|
||||||
declare module "@tiptap/core" {
|
declare module "@tiptap/core" {
|
||||||
interface Commands<ReturnType> {
|
interface Commands<ReturnType> {
|
||||||
@@ -77,7 +79,7 @@ export interface RenderSuggestionsProps {
|
|||||||
command: (item: SlashCommandItem) => void;
|
command: (item: SlashCommandItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceMember = {
|
export interface WorkspaceMember {
|
||||||
publicId: string;
|
publicId: string;
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,7 +87,7 @@ export type WorkspaceMember = {
|
|||||||
image: string | null;
|
image: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const CommandsList = forwardRef<
|
const CommandsList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
@@ -200,7 +202,11 @@ const RenderSuggestions = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MentionItem = { id: string; label: string; image: string | null };
|
interface MentionItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
image: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const MentionList = forwardRef<
|
const MentionList = forwardRef<
|
||||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||||
@@ -238,30 +244,32 @@ const MentionList = forwardRef<
|
|||||||
return (
|
return (
|
||||||
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||||
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
||||||
{items.length > 0 ? items.map((item, index) => (
|
{items.length > 0 ? (
|
||||||
<button
|
items.map((item, index) => (
|
||||||
key={item.id}
|
<button
|
||||||
onClick={() => command(item)}
|
key={item.id}
|
||||||
className={twMerge(
|
onClick={() => command(item)}
|
||||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
className={twMerge(
|
||||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||||
)}
|
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||||
>
|
)}
|
||||||
<Avatar
|
>
|
||||||
size="xs"
|
<Avatar
|
||||||
name={item.label}
|
size="xs"
|
||||||
imageUrl={
|
name={item.label}
|
||||||
item.image ? getAvatarUrl(item.image) : undefined
|
imageUrl={item.image ? getAvatarUrl(item.image) : undefined}
|
||||||
}
|
email={item.label}
|
||||||
email={item.label}
|
/>
|
||||||
/>
|
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
{item.label}
|
||||||
{item.label}
|
</span>
|
||||||
</span>
|
</button>
|
||||||
</button>
|
))
|
||||||
)) : (
|
) : (
|
||||||
<div className="flex items-center justify-start p-2">
|
<div className="flex items-center justify-start p-2">
|
||||||
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
|
<span className="text-[12px] text-dark-900 dark:text-dark-1000">
|
||||||
|
No results
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -436,6 +444,7 @@ export default function Editor({
|
|||||||
{
|
{
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
|
Markdown,
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: readOnly
|
placeholder: readOnly
|
||||||
? ""
|
? ""
|
||||||
@@ -462,35 +471,37 @@ export default function Editor({
|
|||||||
}),
|
}),
|
||||||
Mention.configure({
|
Mention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'mention',
|
class: "mention",
|
||||||
},
|
},
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: "@",
|
char: "@",
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
|
const all: MentionItem[] = workspaceMembers.map(
|
||||||
id: member.publicId,
|
(member: WorkspaceMember) => ({
|
||||||
label: member?.user?.name ?? member.email,
|
id: member.publicId,
|
||||||
image: member?.user?.image ?? null,
|
label: member?.user?.name ?? member.email,
|
||||||
}));
|
image: member?.user?.image ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||||
},
|
},
|
||||||
command: ({ editor, range, props }: any) => {
|
command: ({ editor, range, props }: any) => {
|
||||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.deleteRange(range)
|
.deleteRange(range)
|
||||||
.insertContent(mentionHTML)
|
.insertContent(mentionHTML)
|
||||||
.focus()
|
.focus()
|
||||||
.run();
|
.run();
|
||||||
},
|
|
||||||
render: renderMentionSuggestions,
|
|
||||||
},
|
|
||||||
renderText({ options, node }) {
|
|
||||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
|
||||||
},
|
},
|
||||||
|
render: renderMentionSuggestions,
|
||||||
|
},
|
||||||
|
renderText({ options, node }) {
|
||||||
|
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
import * as boardRepo from "@kan/db/repository/board.repo";
|
import * as boardRepo from "@kan/db/repository/board.repo";
|
||||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||||
|
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||||
import * as importRepo from "@kan/db/repository/import.repo";
|
import * as importRepo from "@kan/db/repository/import.repo";
|
||||||
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
import * as integrationsRepo from "@kan/db/repository/integration.repo";
|
||||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||||
@@ -22,6 +23,7 @@ export interface TrelloBoard {
|
|||||||
labels: TrelloLabel[];
|
labels: TrelloLabel[];
|
||||||
lists: TrelloList[];
|
lists: TrelloList[];
|
||||||
cards: TrelloCard[];
|
cards: TrelloCard[];
|
||||||
|
checklists: TrelloChecklist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TrelloLabel {
|
interface TrelloLabel {
|
||||||
@@ -34,12 +36,34 @@ interface TrelloList {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TrelloChecklist {
|
||||||
|
id: string;
|
||||||
|
idCard: string;
|
||||||
|
name: string;
|
||||||
|
checkItems: TrelloCheckItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrelloCheckItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: "complete" | "incomplete";
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface TrelloCard {
|
interface TrelloCard {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
idList: string;
|
idList: string;
|
||||||
labels: TrelloLabel[];
|
labels: TrelloLabel[];
|
||||||
|
idChecklists: string[];
|
||||||
|
checkItemStates: TrelloCheckItemState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrelloCheckItemState {
|
||||||
|
idChecklist: string;
|
||||||
|
idCheckItem: string;
|
||||||
|
state: "complete" | "incomplete";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const importRouter = createTRPCRouter({
|
export const importRouter = createTRPCRouter({
|
||||||
@@ -170,7 +194,7 @@ export const importRouter = createTRPCRouter({
|
|||||||
|
|
||||||
for (const boardId of input.boardIds) {
|
for (const boardId of input.boardIds) {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all`,
|
`${urls.trello}/boards/${boardId}?key=${apiKey}&token=${integration.accessToken}&lists=open&cards=open&labels=all&checklists=all&checkItemStates=all`,
|
||||||
);
|
);
|
||||||
const data = (await response.json()) as TrelloBoard;
|
const data = (await response.json()) as TrelloBoard;
|
||||||
|
|
||||||
@@ -194,6 +218,18 @@ export const importRouter = createTRPCRouter({
|
|||||||
sourceId: label.id,
|
sourceId: label.id,
|
||||||
name: label.name,
|
name: label.name,
|
||||||
})),
|
})),
|
||||||
|
checklists: data.checklists
|
||||||
|
.filter((checklist) => checklist.idCard === _card.id)
|
||||||
|
.map((_checklist) => ({
|
||||||
|
sourceId: _checklist.id,
|
||||||
|
name: _checklist.name,
|
||||||
|
items: _checklist.checkItems.map((_item) => ({
|
||||||
|
sourceId: _item.id,
|
||||||
|
title: _item.name,
|
||||||
|
completed: _item.state === "complete",
|
||||||
|
index: _item.pos,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -289,6 +325,97 @@ export const importRouter = createTRPCRouter({
|
|||||||
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
await cardActivityRepo.bulkCreate(ctx.db, activities);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const checklistsToCreate: {
|
||||||
|
cardId: number;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
index: number;
|
||||||
|
sourceId: string;
|
||||||
|
items: {
|
||||||
|
sourceId: string;
|
||||||
|
title: string;
|
||||||
|
completed: boolean;
|
||||||
|
index: number;
|
||||||
|
}[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const card of list.cards) {
|
||||||
|
const _card = createdCards.find(
|
||||||
|
(c) => c.sourceId === card.sourceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!_card || !card.checklists.length) continue;
|
||||||
|
|
||||||
|
for (
|
||||||
|
let checklistIndex = 0;
|
||||||
|
checklistIndex < card.checklists.length;
|
||||||
|
checklistIndex++
|
||||||
|
) {
|
||||||
|
const checklist = card.checklists[checklistIndex];
|
||||||
|
if (!checklist) continue;
|
||||||
|
|
||||||
|
checklistsToCreate.push({
|
||||||
|
cardId: _card.id,
|
||||||
|
name: checklist.name,
|
||||||
|
createdBy: userId,
|
||||||
|
index: checklistIndex,
|
||||||
|
sourceId: checklist.sourceId,
|
||||||
|
items: checklist.items.map((item) => ({
|
||||||
|
sourceId: item.sourceId,
|
||||||
|
title: item.title,
|
||||||
|
completed: item.completed,
|
||||||
|
index: item.index,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checklistsToCreate.length > 0) {
|
||||||
|
const newChecklists = await checklistRepo.bulkCreate(
|
||||||
|
ctx.db,
|
||||||
|
checklistsToCreate.map((checklist) => ({
|
||||||
|
cardId: checklist.cardId,
|
||||||
|
name: checklist.name,
|
||||||
|
createdBy: checklist.createdBy,
|
||||||
|
index: checklist.index,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemsToCreate: {
|
||||||
|
checklistId: number;
|
||||||
|
title: string;
|
||||||
|
createdBy: string;
|
||||||
|
index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < checklistsToCreate.length; i++) {
|
||||||
|
const checklistData = checklistsToCreate[i];
|
||||||
|
const newChecklist = newChecklists[i];
|
||||||
|
|
||||||
|
if (!newChecklist || !checklistData?.items.length) continue;
|
||||||
|
|
||||||
|
// NOTE: Sorting here to prevent checklist items being out of order
|
||||||
|
const sortedItems = [...checklistData.items].sort(
|
||||||
|
(a, b) => a.index - b.index,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const item of sortedItems) {
|
||||||
|
itemsToCreate.push({
|
||||||
|
checklistId: newChecklist.id,
|
||||||
|
title: item.title,
|
||||||
|
createdBy: userId,
|
||||||
|
index: item.index,
|
||||||
|
completed: item.completed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsToCreate.length > 0) {
|
||||||
|
await checklistRepo.bulkCreateItems(ctx.db, itemsToCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (createdLabels.length && createdCards.length) {
|
if (createdLabels.length && createdCards.length) {
|
||||||
const cardLabelRelations: {
|
const cardLabelRelations: {
|
||||||
cardId: number;
|
cardId: number;
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const createItem = async (
|
|||||||
checklistId: number;
|
checklistId: number;
|
||||||
title: string;
|
title: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
completed?: boolean;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
@@ -65,7 +66,7 @@ export const createItem = async (
|
|||||||
createdBy: checklistItemInput.createdBy,
|
createdBy: checklistItemInput.createdBy,
|
||||||
checklistId: checklistItemInput.checklistId,
|
checklistId: checklistItemInput.checklistId,
|
||||||
index: lastItem ? lastItem.index + 1 : 0,
|
index: lastItem ? lastItem.index + 1 : 0,
|
||||||
completed: false,
|
completed: checklistItemInput.completed ?? false,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: checklistItems.id,
|
id: checklistItems.id,
|
||||||
@@ -214,3 +215,129 @@ export const updateChecklistById = async (
|
|||||||
.returning({ publicId: checklists.publicId, name: checklists.name });
|
.returning({ publicId: checklists.publicId, name: checklists.name });
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const bulkCreate = async (
|
||||||
|
db: dbClient,
|
||||||
|
checklistInput: {
|
||||||
|
cardId: number;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
index: number;
|
||||||
|
}[],
|
||||||
|
) => {
|
||||||
|
if (checklistInput.length === 0) return [];
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const byCard = groupByKey(checklistInput, "cardId");
|
||||||
|
|
||||||
|
const allValuesToInsert: {
|
||||||
|
publicId: string;
|
||||||
|
cardId: number;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
index: number;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const [cardId, items] of byCard.entries()) {
|
||||||
|
const last = await tx.query.checklists.findFirst({
|
||||||
|
columns: { index: true },
|
||||||
|
where: and(eq(checklists.cardId, cardId), isNull(checklists.deletedAt)),
|
||||||
|
orderBy: [desc(checklists.index)],
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextIndex = last ? last.index + 1 : 0;
|
||||||
|
|
||||||
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
allValuesToInsert.push({
|
||||||
|
publicId: generateUID(),
|
||||||
|
...item,
|
||||||
|
index: nextIndex++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(checklists)
|
||||||
|
.values(allValuesToInsert)
|
||||||
|
.returning({ id: checklists.id, publicId: checklists.publicId });
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bulkCreateItems = async (
|
||||||
|
db: dbClient,
|
||||||
|
checklistItemInput: {
|
||||||
|
checklistId: number;
|
||||||
|
title: string;
|
||||||
|
createdBy: string;
|
||||||
|
index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}[],
|
||||||
|
) => {
|
||||||
|
if (checklistItemInput.length === 0) return [];
|
||||||
|
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const byChecklist = groupByKey(checklistItemInput, "checklistId");
|
||||||
|
|
||||||
|
const allValuesToInsert: {
|
||||||
|
publicId: string;
|
||||||
|
checklistId: number;
|
||||||
|
title: string;
|
||||||
|
createdBy: string;
|
||||||
|
index: number;
|
||||||
|
completed: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const [checklistId, items] of byChecklist.entries()) {
|
||||||
|
const last = await tx.query.checklistItems.findFirst({
|
||||||
|
columns: { index: true },
|
||||||
|
where: and(
|
||||||
|
eq(checklistItems.checklistId, checklistId),
|
||||||
|
isNull(checklistItems.deletedAt),
|
||||||
|
),
|
||||||
|
orderBy: [desc(checklistItems.index)],
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextIndex = last ? last.index + 1 : 0;
|
||||||
|
|
||||||
|
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
allValuesToInsert.push({
|
||||||
|
publicId: generateUID(),
|
||||||
|
...item,
|
||||||
|
index: nextIndex++,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await tx
|
||||||
|
.insert(checklistItems)
|
||||||
|
.values(allValuesToInsert)
|
||||||
|
.returning({
|
||||||
|
id: checklistItems.id,
|
||||||
|
publicId: checklistItems.publicId,
|
||||||
|
title: checklistItems.title,
|
||||||
|
completed: checklistItems.completed,
|
||||||
|
});
|
||||||
|
|
||||||
|
return inserted;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupByKey = <T extends Record<string, unknown>>(
|
||||||
|
items: T[],
|
||||||
|
keyField: keyof T,
|
||||||
|
): Map<number, T[]> => {
|
||||||
|
const grouped = new Map<number, T[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item[keyField] as number;
|
||||||
|
const arr = grouped.get(key) ?? [];
|
||||||
|
arr.push(item);
|
||||||
|
grouped.set(key, arr);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|||||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -223,6 +223,9 @@ importers:
|
|||||||
tippy.js:
|
tippy.js:
|
||||||
specifier: ^6.3.7
|
specifier: ^6.3.7
|
||||||
version: 6.3.7
|
version: 6.3.7
|
||||||
|
tiptap-markdown:
|
||||||
|
specifier: ^0.8
|
||||||
|
version: 0.8.10(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))
|
||||||
zod:
|
zod:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -3575,9 +3578,15 @@ packages:
|
|||||||
'@types/katex@0.16.7':
|
'@types/katex@0.16.7':
|
||||||
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
||||||
|
|
||||||
|
'@types/linkify-it@3.0.5':
|
||||||
|
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0':
|
'@types/linkify-it@5.0.0':
|
||||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
|
|
||||||
|
'@types/markdown-it@13.0.9':
|
||||||
|
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||||
|
|
||||||
@@ -3587,6 +3596,9 @@ packages:
|
|||||||
'@types/mdast@4.0.4':
|
'@types/mdast@4.0.4':
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
|
'@types/mdurl@1.0.5':
|
||||||
|
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
|
||||||
|
|
||||||
'@types/mdurl@2.0.0':
|
'@types/mdurl@2.0.0':
|
||||||
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
|
||||||
|
|
||||||
@@ -5730,6 +5742,9 @@ packages:
|
|||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
|
markdown-it-task-lists@2.1.1:
|
||||||
|
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
|
||||||
|
|
||||||
markdown-it@14.1.0:
|
markdown-it@14.1.0:
|
||||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -7411,6 +7426,11 @@ packages:
|
|||||||
tippy.js@6.3.7:
|
tippy.js@6.3.7:
|
||||||
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
|
resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==}
|
||||||
|
|
||||||
|
tiptap-markdown@0.8.10:
|
||||||
|
resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@tiptap/core': ^2.0.3
|
||||||
|
|
||||||
title-case@2.1.1:
|
title-case@2.1.1:
|
||||||
resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==}
|
resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==}
|
||||||
|
|
||||||
@@ -11321,8 +11341,15 @@ snapshots:
|
|||||||
|
|
||||||
'@types/katex@0.16.7': {}
|
'@types/katex@0.16.7': {}
|
||||||
|
|
||||||
|
'@types/linkify-it@3.0.5': {}
|
||||||
|
|
||||||
'@types/linkify-it@5.0.0': {}
|
'@types/linkify-it@5.0.0': {}
|
||||||
|
|
||||||
|
'@types/markdown-it@13.0.9':
|
||||||
|
dependencies:
|
||||||
|
'@types/linkify-it': 3.0.5
|
||||||
|
'@types/mdurl': 1.0.5
|
||||||
|
|
||||||
'@types/markdown-it@14.1.2':
|
'@types/markdown-it@14.1.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/linkify-it': 5.0.0
|
'@types/linkify-it': 5.0.0
|
||||||
@@ -11336,6 +11363,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
||||||
|
'@types/mdurl@1.0.5': {}
|
||||||
|
|
||||||
'@types/mdurl@2.0.0': {}
|
'@types/mdurl@2.0.0': {}
|
||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
@@ -13777,6 +13806,8 @@ snapshots:
|
|||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
|
markdown-it-task-lists@2.1.1: {}
|
||||||
|
|
||||||
markdown-it@14.1.0:
|
markdown-it@14.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: 2.0.1
|
argparse: 2.0.1
|
||||||
@@ -16085,6 +16116,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@popperjs/core': 2.11.8
|
'@popperjs/core': 2.11.8
|
||||||
|
|
||||||
|
tiptap-markdown@0.8.10(@tiptap/core@2.26.1(@tiptap/pm@2.26.1)):
|
||||||
|
dependencies:
|
||||||
|
'@tiptap/core': 2.26.1(@tiptap/pm@2.26.1)
|
||||||
|
'@types/markdown-it': 13.0.9
|
||||||
|
markdown-it: 14.1.0
|
||||||
|
markdown-it-task-lists: 2.1.1
|
||||||
|
prosemirror-markdown: 1.13.2
|
||||||
|
|
||||||
title-case@2.1.1:
|
title-case@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 2.3.2
|
no-case: 2.3.2
|
||||||
@@ -16466,7 +16505,7 @@ snapshots:
|
|||||||
'@webassemblyjs/wasm-parser': 1.14.1
|
'@webassemblyjs/wasm-parser': 1.14.1
|
||||||
acorn: 8.15.0
|
acorn: 8.15.0
|
||||||
acorn-import-phases: 1.0.4(acorn@8.15.0)
|
acorn-import-phases: 1.0.4(acorn@8.15.0)
|
||||||
browserslist: 4.25.4
|
browserslist: 4.27.0
|
||||||
chrome-trace-event: 1.0.4
|
chrome-trace-event: 1.0.4
|
||||||
enhanced-resolve: 5.18.3
|
enhanced-resolve: 5.18.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user