mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7c61580f5 | |||
| 9c8dda0e0c | |||
| 40b7bea982 | |||
| 1c184936f9 | |||
| f654b7e136 | |||
| fb9021e9e1 | |||
| 866eea1dcf | |||
| eade6eb392 | |||
| a4ee81fa19 | |||
| 1330724c75 | |||
| 5ca9fc9b44 | |||
| 4c7bc07b28 | |||
| a033b08c83 | |||
| f7d5d25247 | |||
| 234e2d84ed | |||
| 4389ac0d1d | |||
| f60f5fd66d | |||
| bee61ce1ef | |||
| 20f5e953b7 | |||
| c2968a671c | |||
| 98959dc330 | |||
| 7fc305b5d5 | |||
| 1b90ab85e7 | |||
| 4faf1b8570 | |||
| c61bc5dedb | |||
| 3afde6962b | |||
| cf7c97e9d6 | |||
| 6298d7b31b | |||
| 99ec9c1627 | |||
| fdc53b91f8 | |||
| eb3f74cf21 | |||
| 8656c21e14 | |||
| 24fd606a86 | |||
| fe9a548490 | |||
| f71afc2bf5 | |||
| 9e7dd5b4f7 | |||
| 021e431195 | |||
| 2136be9327 | |||
| 581502f7e2 | |||
| 75447cd782 | |||
| fee3e7d0c3 | |||
| aed55c7cfd | |||
| 180d17e173 | |||
| aa60f5ccea | |||
| ea79883e04 | |||
| e9afc1d91f | |||
| ed47b9eda0 | |||
| 5a19182757 | |||
| 51b3971d21 | |||
| 057a1bbc7f | |||
| 1289f5f3be | |||
| 429de07820 | |||
| 68489973e0 | |||
| 4dbd5b3617 | |||
| f9fe1cc308 | |||
| fe03ba8710 | |||
| d13770ddf9 | |||
| b7425fefc6 | |||
| 79df4b030b | |||
| 24eaeca47e | |||
| db0deb6997 | |||
| 9f21e57335 | |||
| fa6b83382b | |||
| b86475360f | |||
| 9b179a2612 | |||
| 7afe69e22a | |||
| 2ac19e3938 | |||
| a25968d4a7 | |||
| 87b8e5daeb | |||
| 94a862ce01 |
@@ -1,4 +1,3 @@
|
||||
import uniq from "lodash/uniq";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -19,7 +18,6 @@ import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { EmptySelectValue } from "~/types";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -32,26 +30,6 @@ export interface FormData {
|
||||
permission: CollectionPermission | undefined;
|
||||
}
|
||||
|
||||
const useIconColor = (collection?: Collection) => {
|
||||
const { collections } = useStores();
|
||||
const hasMultipleCollections = collections.orderedData.length > 1;
|
||||
const collectionColors = uniq(
|
||||
collections.orderedData.map((c) => c.color).filter(Boolean)
|
||||
) as string[];
|
||||
|
||||
const iconColor = React.useMemo(
|
||||
() =>
|
||||
collection?.color ??
|
||||
// If all the existing collections have the same color, use that color,
|
||||
// otherwise pick a random color from the palette
|
||||
(hasMultipleCollections && collectionColors.length === 1
|
||||
? collectionColors[0]
|
||||
: randomElement(colorPalette)),
|
||||
[collection?.color]
|
||||
);
|
||||
return iconColor;
|
||||
};
|
||||
|
||||
export const CollectionForm = observer(function CollectionForm_({
|
||||
handleSubmit,
|
||||
collection,
|
||||
@@ -64,7 +42,11 @@ export const CollectionForm = observer(function CollectionForm_({
|
||||
|
||||
const [hasOpenedIconPicker, setHasOpenedIconPicker] = useBoolean(false);
|
||||
|
||||
const iconColor = useIconColor(collection);
|
||||
const iconColor = React.useMemo(
|
||||
() => collection?.color ?? randomElement(colorPalette),
|
||||
[collection?.color]
|
||||
);
|
||||
|
||||
const fallbackIcon = <Icon value="collection" color={iconColor} />;
|
||||
|
||||
const {
|
||||
|
||||
+4
-26
@@ -6,18 +6,15 @@ import { toast } from "sonner";
|
||||
import styled from "styled-components";
|
||||
import { richExtensions } from "@shared/editor/nodes";
|
||||
import { s } from "@shared/styles";
|
||||
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Editor from "~/components/Editor";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import Text from "~/components/Text";
|
||||
import { withUIExtensions } from "~/editor/extensions";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Properties } from "~/types";
|
||||
import Text from "./Text";
|
||||
|
||||
const extensions = withUIExtensions(richExtensions);
|
||||
|
||||
@@ -25,8 +22,8 @@ type Props = {
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
function Overview({ collection }: Props) {
|
||||
const { documents, collections } = useStores();
|
||||
function CollectionDescription({ collection }: Props) {
|
||||
const { collections } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser({ rejectOnEmpty: true });
|
||||
const can = usePolicy(collection);
|
||||
@@ -57,24 +54,6 @@ function Overview({ collection }: Props) {
|
||||
[childOffsetHeight]
|
||||
);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (params: Properties<Document>) => {
|
||||
const newDocument = await documents.create(
|
||||
{
|
||||
collectionId: collection.id,
|
||||
data: ProsemirrorHelper.getEmptyDocument(),
|
||||
...params,
|
||||
},
|
||||
{
|
||||
publish: true,
|
||||
}
|
||||
);
|
||||
|
||||
return newDocument.url;
|
||||
},
|
||||
[collection, documents]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{collections.isSaving && <LoadingIndicator />}
|
||||
@@ -86,7 +65,6 @@ function Overview({ collection }: Props) {
|
||||
placeholder={`${t("Add a description")}…`}
|
||||
extensions={extensions}
|
||||
maxLength={CollectionValidation.maxDescriptionLength}
|
||||
onCreateLink={onCreateLink}
|
||||
canUpdate={can.update}
|
||||
readOnly={!can.update}
|
||||
userId={user.id}
|
||||
@@ -105,4 +83,4 @@ const Placeholder = styled(Text)`
|
||||
min-height: 27px;
|
||||
`;
|
||||
|
||||
export default observer(Overview);
|
||||
export default observer(CollectionDescription);
|
||||
@@ -20,6 +20,7 @@ import Collection from "~/models/Collection";
|
||||
import { Action } from "~/components/Actions";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import { CollectionBreadcrumb } from "~/components/CollectionBreadcrumb";
|
||||
import CollectionDescription from "~/components/CollectionDescription";
|
||||
import Heading from "~/components/Heading";
|
||||
import CollectionIcon from "~/components/Icons/CollectionIcon";
|
||||
import InputSearchPage from "~/components/InputSearchPage";
|
||||
@@ -45,7 +46,6 @@ import DropToImport from "./components/DropToImport";
|
||||
import Empty from "./components/Empty";
|
||||
import MembershipPreview from "./components/MembershipPreview";
|
||||
import Notices from "./components/Notices";
|
||||
import Overview from "./components/Overview";
|
||||
import ShareButton from "./components/ShareButton";
|
||||
|
||||
const IconPicker = React.lazy(() => import("~/components/IconPicker"));
|
||||
@@ -259,7 +259,7 @@ const CollectionScene = observer(function _CollectionScene() {
|
||||
path={collectionPath(collection.path, CollectionPath.Overview)}
|
||||
>
|
||||
{hasOverview ? (
|
||||
<Overview collection={collection} />
|
||||
<CollectionDescription collection={collection} />
|
||||
) : (
|
||||
<Redirect
|
||||
to={{
|
||||
|
||||
+17
-18
@@ -48,18 +48,18 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.803.0",
|
||||
"@aws-sdk/lib-storage": "3.803.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.803.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.803.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.803.0",
|
||||
"@babel/core": "^7.27.1",
|
||||
"@babel/plugin-proposal-decorators": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-destructuring": "^7.27.1",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.1",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@aws-sdk/client-s3": "3.797.0",
|
||||
"@aws-sdk/lib-storage": "3.797.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.797.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.797.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.796.0",
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/plugin-proposal-decorators": "^7.25.9",
|
||||
"@babel/plugin-transform-class-properties": "^7.25.9",
|
||||
"@babel/plugin-transform-destructuring": "^7.25.9",
|
||||
"@babel/plugin-transform-regenerator": "^7.27.0",
|
||||
"@babel/preset-env": "^7.26.9",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@benrbray/prosemirror-math": "^0.2.2",
|
||||
"@bull-board/api": "^6.7.10",
|
||||
"@bull-board/koa": "^6.7.10",
|
||||
@@ -208,7 +208,7 @@
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-medium-image-zoom": "5.2.14",
|
||||
"react-medium-image-zoom": "5.2.13",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
@@ -228,7 +228,6 @@
|
||||
"sequelize": "^6.37.3",
|
||||
"sequelize-cli": "^6.6.2",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
"sequelize-strict-attributes": "^1.0.2",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.6",
|
||||
@@ -249,7 +248,7 @@
|
||||
"umzug": "^3.8.2",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^8.3.2",
|
||||
"validator": "13.15.0",
|
||||
"validator": "13.12.0",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
@@ -263,8 +262,8 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@babel/cli": "^7.27.0",
|
||||
"@babel/preset-typescript": "^7.27.0",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.3.0",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
@@ -329,7 +328,7 @@
|
||||
"@types/tmp": "^0.2.6",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@types/validator": "^13.15.0",
|
||||
"@types/validator": "^13.12.1",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
|
||||
@@ -288,7 +288,7 @@ export class NotionConverter {
|
||||
if (item.mention.type === "link_mention") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text || item.mention.link_mention.href,
|
||||
text: item.plain_text,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
@@ -302,7 +302,7 @@ export class NotionConverter {
|
||||
if (item.mention.type === "link_preview") {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text || item.mention.link_preview.url,
|
||||
text: item.plain_text,
|
||||
marks: [
|
||||
{
|
||||
type: "link",
|
||||
@@ -314,14 +314,14 @@ export class NotionConverter {
|
||||
};
|
||||
}
|
||||
|
||||
if (item.plain_text) {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
};
|
||||
if (!item.plain_text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return {
|
||||
type: "text",
|
||||
text: item.plain_text,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === "equation") {
|
||||
@@ -336,20 +336,20 @@ export class NotionConverter {
|
||||
};
|
||||
}
|
||||
|
||||
if (item.text.content) {
|
||||
return {
|
||||
type: "text",
|
||||
text: item.text.content,
|
||||
marks: [
|
||||
...mapAttrs(),
|
||||
...(item.text.link
|
||||
? [{ type: "link", attrs: { href: item.text.link.url } }]
|
||||
: []),
|
||||
].filter(Boolean),
|
||||
};
|
||||
if (!item.text.content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return {
|
||||
type: "text",
|
||||
text: item.text.content,
|
||||
marks: [
|
||||
...mapAttrs(),
|
||||
...(item.text.link
|
||||
? [{ type: "link", attrs: { href: item.text.link.url } }]
|
||||
: []),
|
||||
].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private static rich_text_to_plaintext(item: RichTextItemResponse) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import { Op, WhereOptions } from "sequelize";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import { UrlHelper } from "@shared/utils/UrlHelper";
|
||||
@@ -21,8 +22,8 @@ type Props = {
|
||||
|
||||
type Result = {
|
||||
document: Document;
|
||||
share: Share | null;
|
||||
collection: Collection | null;
|
||||
share?: Share;
|
||||
collection?: Collection | null;
|
||||
};
|
||||
|
||||
export default async function loadDocument({
|
||||
@@ -32,9 +33,9 @@ export default async function loadDocument({
|
||||
user,
|
||||
includeState,
|
||||
}: Props): Promise<Result> {
|
||||
let document: Document | null = null;
|
||||
let collection: Collection | null = null;
|
||||
let share: Share | null = null;
|
||||
let document;
|
||||
let collection;
|
||||
let share;
|
||||
|
||||
if (!shareId && !(id && user)) {
|
||||
throw AuthenticationError(`Authentication or shareId required`);
|
||||
@@ -71,7 +72,20 @@ export default async function loadDocument({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Document.scope("withDrafts"),
|
||||
// unscoping here allows us to return unpublished documents
|
||||
model: Document.unscoped(),
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
{
|
||||
model: User,
|
||||
as: "updatedBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
required: true,
|
||||
as: "document",
|
||||
},
|
||||
@@ -115,13 +129,14 @@ export default async function loadDocument({
|
||||
const canReadDocument = user && can(user, "read", document);
|
||||
|
||||
if (canReadDocument) {
|
||||
// Cannot use document.collection here as it does not include the
|
||||
// documentStructure by default through the relationship.
|
||||
if (document.collectionId) {
|
||||
collection = await Collection.scope("withDocumentStructure").findByPk(
|
||||
document.collectionId,
|
||||
{
|
||||
rejectOnEmpty: true,
|
||||
}
|
||||
);
|
||||
collection = await Collection.findByPk(document.collectionId);
|
||||
|
||||
if (!collection) {
|
||||
throw NotFoundError("Collection could not be found for document");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -140,15 +155,11 @@ export default async function loadDocument({
|
||||
|
||||
// It is possible to disable sharing at the collection so we must check
|
||||
if (document.collectionId) {
|
||||
collection = await Collection.scope("withDocumentStructure").findByPk(
|
||||
document.collectionId,
|
||||
{
|
||||
rejectOnEmpty: true,
|
||||
}
|
||||
);
|
||||
collection = await Collection.findByPk(document.collectionId);
|
||||
}
|
||||
invariant(collection, "collection not found");
|
||||
|
||||
if (!collection?.sharing) {
|
||||
if (!collection.sharing) {
|
||||
throw AuthorizationError();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { createContext } from "@server/context";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
@@ -65,21 +66,16 @@ async function documentMover({
|
||||
result.documents.push(document);
|
||||
} else {
|
||||
// Load the current and the next collection upfront and lock them
|
||||
const collection = await Collection.scope("withDocumentStructure").findByPk(
|
||||
document.collectionId!,
|
||||
{
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
}
|
||||
);
|
||||
const collection = await Collection.findByPk(document.collectionId!, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
});
|
||||
|
||||
let newCollection = collection;
|
||||
if (collectionChanged) {
|
||||
if (collectionId) {
|
||||
newCollection = await Collection.scope(
|
||||
"withDocumentStructure"
|
||||
).findByPk(collectionId, {
|
||||
newCollection = await Collection.findByPk(collectionId, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -148,14 +144,12 @@ async function documentMover({
|
||||
|
||||
if (collectionId) {
|
||||
// Reload the collection to get relationship data
|
||||
newCollection = await Collection.scope([
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findByPk(collectionId, {
|
||||
newCollection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
invariant(newCollection, "Collection not found");
|
||||
|
||||
result.collections.push(newCollection);
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up() {
|
||||
if (
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.DEPLOYMENT === "hosted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptName = path.basename(__filename);
|
||||
const scriptPath = path.join(
|
||||
process.cwd(),
|
||||
"build",
|
||||
`server/scripts/${scriptName}`
|
||||
);
|
||||
|
||||
execFileSync("node", [scriptPath], { stdio: "inherit" });
|
||||
},
|
||||
|
||||
async down() {
|
||||
// noop
|
||||
},
|
||||
};
|
||||
@@ -16,7 +16,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("#url", () => {
|
||||
it("should return correct url for the collection", () => {
|
||||
test("should return correct url for the collection", () => {
|
||||
const collection = new Collection({
|
||||
id: "1234",
|
||||
});
|
||||
@@ -25,7 +25,7 @@ describe("#url", () => {
|
||||
});
|
||||
|
||||
describe("getDocumentParents", () => {
|
||||
it("should return array of parent document ids", async () => {
|
||||
test("should return array of parent document ids", async () => {
|
||||
const parent = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
@@ -41,7 +41,7 @@ describe("getDocumentParents", () => {
|
||||
expect(result ? result[0] : undefined).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("should return array of parent document ids", async () => {
|
||||
test("should return array of parent document ids", async () => {
|
||||
const parent = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
@@ -56,7 +56,7 @@ describe("getDocumentParents", () => {
|
||||
expect(result?.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should not error if documentStructure is empty", async () => {
|
||||
test("should not error if documentStructure is empty", async () => {
|
||||
const parent = await buildDocument();
|
||||
await buildDocument();
|
||||
const collection = await buildCollection();
|
||||
@@ -66,7 +66,7 @@ describe("getDocumentParents", () => {
|
||||
});
|
||||
|
||||
describe("getDocumentTree", () => {
|
||||
it("should return document tree", async () => {
|
||||
test("should return document tree", async () => {
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
documentStructure: [await document.toNavigationNode()],
|
||||
@@ -76,7 +76,7 @@ describe("getDocumentTree", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should return nested documents in tree", async () => {
|
||||
test("should return nested documents in tree", async () => {
|
||||
const parent = await buildDocument();
|
||||
const document = await buildDocument();
|
||||
const collection = await buildCollection({
|
||||
@@ -99,7 +99,7 @@ describe("getDocumentTree", () => {
|
||||
});
|
||||
|
||||
describe("#addDocumentToStructure", () => {
|
||||
it("should add as last element without index", async () => {
|
||||
test("should add as last element without index", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = uuidv4();
|
||||
const newDocument = await buildDocument({
|
||||
@@ -117,7 +117,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure!.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should add with an index", async () => {
|
||||
test("should add with an index", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = uuidv4();
|
||||
const newDocument = await buildDocument({
|
||||
@@ -131,7 +131,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].id).toBe(id);
|
||||
});
|
||||
|
||||
it("should add as a child if with parent", async () => {
|
||||
test("should add as a child if with parent", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -150,7 +150,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
|
||||
it("should add as a child if with parent with index", async () => {
|
||||
test("should add as a child if with parent with index", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -176,7 +176,7 @@ describe("#addDocumentToStructure", () => {
|
||||
expect(collection.documentStructure![0].children[0].id).toBe(id);
|
||||
});
|
||||
|
||||
it("should add the document along with its nested document(s)", async () => {
|
||||
test("should add the document along with its nested document(s)", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -204,7 +204,7 @@ describe("#addDocumentToStructure", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should add the document along with its archived nested document(s)", async () => {
|
||||
test("should add the document along with its archived nested document(s)", async () => {
|
||||
const collection = await buildCollection();
|
||||
|
||||
const document = await buildDocument({
|
||||
@@ -237,7 +237,7 @@ describe("#addDocumentToStructure", () => {
|
||||
);
|
||||
});
|
||||
describe("options: documentJson", () => {
|
||||
it("should append supplied json over document's own", async () => {
|
||||
test("should append supplied json over document's own", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = uuidv4();
|
||||
const newDocument = await buildDocument({
|
||||
@@ -268,7 +268,7 @@ describe("#addDocumentToStructure", () => {
|
||||
});
|
||||
|
||||
describe("#updateDocument", () => {
|
||||
it("should update root document's data", async () => {
|
||||
test("should update root document's data", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -279,7 +279,7 @@ describe("#updateDocument", () => {
|
||||
expect(collection.documentStructure![0].title).toBe("Updated title");
|
||||
});
|
||||
|
||||
it("should update child document's data", async () => {
|
||||
test("should update child document's data", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -297,7 +297,7 @@ describe("#updateDocument", () => {
|
||||
newDocument.title = "Updated title";
|
||||
await newDocument.save();
|
||||
await collection.updateDocument(newDocument);
|
||||
const reloaded = await collection.reload();
|
||||
const reloaded = await Collection.findByPk(collection.id);
|
||||
expect(reloaded!.documentStructure![0].children[0].title).toBe(
|
||||
"Updated title"
|
||||
);
|
||||
@@ -305,7 +305,7 @@ describe("#updateDocument", () => {
|
||||
});
|
||||
|
||||
describe("#removeDocument", () => {
|
||||
it("should save if removing", async () => {
|
||||
test("should save if removing", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -315,7 +315,7 @@ describe("#removeDocument", () => {
|
||||
expect(collection.save).toBeCalled();
|
||||
});
|
||||
|
||||
it("should remove documents from root", async () => {
|
||||
test("should remove documents from root", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -331,7 +331,7 @@ describe("#removeDocument", () => {
|
||||
expect(collectionDocuments.count).toBe(0);
|
||||
});
|
||||
|
||||
it("should remove a document with child documents", async () => {
|
||||
test("should remove a document with child documents", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -359,7 +359,7 @@ describe("#removeDocument", () => {
|
||||
expect(collectionDocuments.count).toBe(0);
|
||||
});
|
||||
|
||||
it("should remove a child document", async () => {
|
||||
test("should remove a child document", async () => {
|
||||
const collection = await buildCollection();
|
||||
const document = await buildDocument({ collectionId: collection.id });
|
||||
await collection.reload();
|
||||
@@ -380,7 +380,7 @@ describe("#removeDocument", () => {
|
||||
expect(collection.documentStructure![0].children.length).toBe(1);
|
||||
// Remove the document
|
||||
await collection.deleteDocument(newDocument);
|
||||
const reloaded = await collection.reload();
|
||||
const reloaded = await Collection.findByPk(collection.id);
|
||||
expect(reloaded!.documentStructure!.length).toBe(1);
|
||||
expect(reloaded!.documentStructure![0].children.length).toBe(0);
|
||||
const collectionDocuments = await Document.findAndCountAll({
|
||||
@@ -393,7 +393,7 @@ describe("#removeDocument", () => {
|
||||
});
|
||||
|
||||
describe("#membershipUserIds", () => {
|
||||
it("should return collection and group memberships", async () => {
|
||||
test("should return collection and group memberships", async () => {
|
||||
const team = await buildTeam();
|
||||
const teamId = team.id;
|
||||
// Make 6 users
|
||||
@@ -464,53 +464,47 @@ describe("#membershipUserIds", () => {
|
||||
});
|
||||
|
||||
describe("#findByPk", () => {
|
||||
it("should return collection with collection Id", async () => {
|
||||
test("should return collection with collection Id", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
it("should not return documentStructure by default", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id);
|
||||
expect(() => response!.documentStructure).toThrow();
|
||||
});
|
||||
|
||||
it("should return collection when urlId is present", async () => {
|
||||
test("should return collection when urlId is present", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
it("should return collection when urlId is present, but missing slug", async () => {
|
||||
test("should return collection when urlId is present, but missing slug", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = collection.urlId;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response!.id).toBe(collection.id);
|
||||
});
|
||||
|
||||
it("should return null when incorrect uuid type", async () => {
|
||||
test("should return null when incorrect uuid type", async () => {
|
||||
const collection = await buildCollection();
|
||||
const response = await Collection.findByPk(collection.id + "-incorrect");
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when incorrect urlId length", async () => {
|
||||
test("should return null when incorrect urlId length", async () => {
|
||||
const collection = await buildCollection();
|
||||
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when no collection is found with uuid", async () => {
|
||||
test("should return null when no collection is found with uuid", async () => {
|
||||
const response = await Collection.findByPk(
|
||||
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
|
||||
);
|
||||
expect(response).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when no collection is found with urlId", async () => {
|
||||
test("should return null when no collection is found with urlId", async () => {
|
||||
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
|
||||
const response = await Collection.findByPk(id);
|
||||
expect(response).toBe(null);
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
AllowNull,
|
||||
BeforeCreate,
|
||||
BeforeUpdate,
|
||||
DefaultScope,
|
||||
} from "sequelize-typescript";
|
||||
import isUUID from "validator/lib/isUUID";
|
||||
import type { CollectionSort, ProsemirrorData } from "@shared/types";
|
||||
@@ -70,11 +69,6 @@ type AdditionalFindOptions = {
|
||||
rejectOnEmpty?: boolean | Error;
|
||||
};
|
||||
|
||||
@DefaultScope(() => ({
|
||||
attributes: {
|
||||
exclude: ["documentStructure"],
|
||||
},
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withAllMemberships: {
|
||||
include: [
|
||||
@@ -127,12 +121,6 @@ type AdditionalFindOptions = {
|
||||
},
|
||||
],
|
||||
}),
|
||||
withDocumentStructure: () => ({
|
||||
attributes: {
|
||||
// resets to include the documentStructure column
|
||||
exclude: [],
|
||||
},
|
||||
}),
|
||||
withMembership: (userId: string) => {
|
||||
if (!userId) {
|
||||
return {};
|
||||
@@ -250,7 +238,6 @@ class Collection extends ParanoidModel<
|
||||
@Column
|
||||
maintainerApprovalRequired: boolean;
|
||||
|
||||
@Default(null)
|
||||
@Column(DataType.JSONB)
|
||||
documentStructure: NavigationNode[] | null;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
buildUser,
|
||||
buildGuestUser,
|
||||
} from "@server/test/factories";
|
||||
import Collection from "./Collection";
|
||||
import UserMembership from "./UserMembership";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -95,8 +96,10 @@ describe("#delete", () => {
|
||||
|
||||
await document.delete(user);
|
||||
const [newDocument, newCollection] = await Promise.all([
|
||||
document.reload({ paranoid: false }),
|
||||
collection.reload(),
|
||||
Document.findByPk(document.id, {
|
||||
paranoid: false,
|
||||
}),
|
||||
Collection.findByPk(collection.id),
|
||||
]);
|
||||
|
||||
expect(newDocument?.lastModifiedById).toEqual(user.id);
|
||||
|
||||
+23
-53
@@ -15,7 +15,6 @@ import {
|
||||
FindOptions,
|
||||
WhereOptions,
|
||||
EmptyResultError,
|
||||
Sequelize,
|
||||
} from "sequelize";
|
||||
import {
|
||||
ForeignKey,
|
||||
@@ -105,18 +104,10 @@ type AdditionalFindOptions = {
|
||||
exclude: ["state"],
|
||||
},
|
||||
}))
|
||||
// @ts-expect-error Type 'Literal' is not assignable to type 'string | ProjectionAlias'.
|
||||
@Scopes(() => ({
|
||||
withoutState: {
|
||||
attributes: {
|
||||
include: [
|
||||
Sequelize.literal(
|
||||
// If content (JSON) is null then we still need to return the state column (BINARY)
|
||||
// as it's used as a fallback for content deserialization for older documents.
|
||||
// This can be removed if content is 100% backfilled.
|
||||
`CASE WHEN document.content IS NULL THEN document.state ELSE NULL END AS state`
|
||||
),
|
||||
],
|
||||
exclude: ["state"],
|
||||
},
|
||||
},
|
||||
withCollection: {
|
||||
@@ -171,13 +162,11 @@ type AdditionalFindOptions = {
|
||||
return {
|
||||
include: [
|
||||
{
|
||||
attributes: ["id", "permission", "sharing", "teamId", "deletedAt"],
|
||||
model: userId
|
||||
? Collection.scope([
|
||||
"defaultScope",
|
||||
{
|
||||
method: ["withMembership", userId],
|
||||
},
|
||||
])
|
||||
? Collection.scope({
|
||||
method: ["withMembership", userId],
|
||||
})
|
||||
: Collection,
|
||||
as: "collection",
|
||||
paranoid,
|
||||
@@ -425,13 +414,10 @@ class Document extends ArchivableModel<
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = await Collection.scope("withDocumentStructure").findByPk(
|
||||
model.collectionId,
|
||||
{
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
}
|
||||
);
|
||||
const collection = await Collection.findByPk(model.collectionId, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
@@ -452,9 +438,7 @@ class Document extends ArchivableModel<
|
||||
}
|
||||
|
||||
return this.sequelize!.transaction(async (transaction: Transaction) => {
|
||||
const collection = await Collection.scope(
|
||||
"withDocumentStructure"
|
||||
).findByPk(model.collectionId!, {
|
||||
const collection = await Collection.findByPk(model.collectionId!, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -648,13 +632,9 @@ class Document extends ArchivableModel<
|
||||
return uniq(membershipUserIds);
|
||||
}
|
||||
|
||||
static withMembershipScope(
|
||||
userId: string,
|
||||
options?: FindOptions<Document> & { includeDrafts?: boolean }
|
||||
) {
|
||||
static withMembershipScope(userId: string, options?: FindOptions<Document>) {
|
||||
return this.scope([
|
||||
options?.includeDrafts ? "withDrafts" : "defaultScope",
|
||||
"withoutState",
|
||||
"defaultScope",
|
||||
{
|
||||
method: ["withViews", userId],
|
||||
},
|
||||
@@ -942,9 +922,7 @@ class Document extends ArchivableModel<
|
||||
}
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.scope(
|
||||
"withDocumentStructure"
|
||||
).findByPk(this.collectionId, {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
@@ -1011,13 +989,10 @@ class Document extends ArchivableModel<
|
||||
|
||||
await this.sequelize.transaction(async (transaction: Transaction) => {
|
||||
const collection = this.collectionId
|
||||
? await Collection.scope("withDocumentStructure").findByPk(
|
||||
this.collectionId,
|
||||
{
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
}
|
||||
)
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (collection) {
|
||||
@@ -1048,13 +1023,10 @@ class Document extends ArchivableModel<
|
||||
archive = async (user: User, options?: FindOptions) => {
|
||||
const { transaction } = { ...options };
|
||||
const collection = this.collectionId
|
||||
? await Collection.scope("withDocumentStructure").findByPk(
|
||||
this.collectionId,
|
||||
{
|
||||
transaction,
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
}
|
||||
)
|
||||
? await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (collection) {
|
||||
@@ -1075,7 +1047,7 @@ class Document extends ArchivableModel<
|
||||
) => {
|
||||
const { transaction } = { ...options };
|
||||
const collection = collectionId
|
||||
? await Collection.scope("withDocumentStructure").findByPk(collectionId, {
|
||||
? await Collection.findByPk(collectionId, {
|
||||
transaction,
|
||||
lock: transaction?.LOCK.UPDATE,
|
||||
})
|
||||
@@ -1127,9 +1099,7 @@ class Document extends ArchivableModel<
|
||||
let deleted = false;
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
const collection = await Collection.scope(
|
||||
"withDocumentStructure"
|
||||
).findByPk(this.collectionId!, {
|
||||
const collection = await Collection.findByPk(this.collectionId!, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
paranoid: false,
|
||||
|
||||
@@ -182,16 +182,16 @@ export default class SearchHelper {
|
||||
},
|
||||
];
|
||||
|
||||
return Document.withMembershipScope(user.id, {
|
||||
includeDrafts: true,
|
||||
}).findAll({
|
||||
where,
|
||||
subQuery: false,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
return Document.withMembershipScope(user.id)
|
||||
.scope("withDrafts")
|
||||
.findAll({
|
||||
where,
|
||||
subQuery: false,
|
||||
order: [["updatedAt", "DESC"]],
|
||||
include,
|
||||
offset,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
|
||||
public static async searchCollectionsForUser(
|
||||
@@ -264,12 +264,14 @@ export default class SearchHelper {
|
||||
|
||||
// Final query to get associated document data
|
||||
const [documents, count] = await Promise.all([
|
||||
Document.withMembershipScope(user.id, { includeDrafts: true }).findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
id: map(results, "id"),
|
||||
},
|
||||
}),
|
||||
Document.withMembershipScope(user.id)
|
||||
.scope("withDrafts")
|
||||
.findAll({
|
||||
where: {
|
||||
teamId: user.teamId,
|
||||
id: map(results, "id"),
|
||||
},
|
||||
}),
|
||||
results.length < limit && offset === 0
|
||||
? Promise.resolve(results.length)
|
||||
: countQuery,
|
||||
|
||||
@@ -140,11 +140,9 @@ router.post(
|
||||
async (ctx: APIContext<T.CollectionsDocumentsReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const collection = await Collection.scope([
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findByPk(id);
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "readDocument", collection);
|
||||
|
||||
|
||||
@@ -977,7 +977,7 @@ describe("#documents.list", () => {
|
||||
const res = await server.post("/api/documents.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: document.collectionId,
|
||||
collection: document.collectionId,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
@@ -1013,7 +1013,7 @@ describe("#documents.list", () => {
|
||||
const res = await server.post("/api/documents.list", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
collection: collection.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
@@ -133,19 +133,15 @@ router.post(
|
||||
// if a specific collection is passed then we need to check auth to view it
|
||||
if (collectionId) {
|
||||
where[Op.and].push({ collectionId: [collectionId] });
|
||||
const collection = await Collection.scope([
|
||||
sort === "index" ? "withDocumentStructure" : "defaultScope",
|
||||
{
|
||||
method: ["withMembership", user.id],
|
||||
},
|
||||
]).findByPk(collectionId);
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId);
|
||||
authorize(user, "readDocument", collection);
|
||||
|
||||
// index sort is special because it uses the order of the documents in the
|
||||
// collection.documentStructure rather than a database column
|
||||
if (sort === "index") {
|
||||
documentIds = (collection.documentStructure || [])
|
||||
documentIds = (collection?.documentStructure || [])
|
||||
.map((node) => node.id)
|
||||
.slice(ctx.state.pagination.offset, ctx.state.pagination.limit);
|
||||
where[Op.and].push({ id: documentIds });
|
||||
@@ -539,14 +535,14 @@ router.post(
|
||||
delete where.updatedAt;
|
||||
}
|
||||
|
||||
const documents = await Document.withMembershipScope(user.id, {
|
||||
includeDrafts: true,
|
||||
}).findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
const documents = await Document.withMembershipScope(user.id)
|
||||
.scope("withDrafts")
|
||||
.findAll({
|
||||
where,
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
const data = await Promise.all(
|
||||
documents.map((document) => presentDocument(ctx, document))
|
||||
);
|
||||
|
||||
@@ -58,13 +58,13 @@ router.post(
|
||||
const documentIds = memberships
|
||||
.map((p) => p.documentId)
|
||||
.filter(Boolean) as string[];
|
||||
const documents = await Document.withMembershipScope(userId, {
|
||||
includeDrafts: true,
|
||||
}).findAll({
|
||||
where: {
|
||||
id: documentIds,
|
||||
},
|
||||
});
|
||||
const documents = await Document.withMembershipScope(userId)
|
||||
.scope("withDrafts")
|
||||
.findAll({
|
||||
where: {
|
||||
id: documentIds,
|
||||
},
|
||||
});
|
||||
|
||||
const groups = uniqBy(
|
||||
memberships.map((membership) => membership.group),
|
||||
|
||||
@@ -54,11 +54,7 @@ router.post(
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
const collection = document.collectionId
|
||||
? await Collection.scope("withDocumentStructure").findByPk(
|
||||
document.collectionId
|
||||
)
|
||||
: undefined;
|
||||
const collection = await document.$get("collection");
|
||||
const parentIds = collection?.getDocumentParents(documentId);
|
||||
const parentShare = parentIds
|
||||
? await Share.scope({
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import "./bootstrap";
|
||||
import fractionalIndex from "fractional-index";
|
||||
import { Sequelize, Transaction } from "sequelize";
|
||||
import { Collection, Team } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
const limit = 100;
|
||||
|
||||
class CollectionIndexCollisionResolver {
|
||||
private teamId: string;
|
||||
private currDuplicateIndex: string | null = null;
|
||||
private currDuplicateGroup: Collection[] = [];
|
||||
private resolvedCollisionsCount: number = 0;
|
||||
|
||||
constructor(teamId: string) {
|
||||
this.teamId = teamId;
|
||||
}
|
||||
|
||||
public async process() {
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await this.processPage(0, transaction);
|
||||
// edge case of last batch
|
||||
await this.resolveDuplicates({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
private async processPage(
|
||||
page: number,
|
||||
transaction: Transaction
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`Resolve collection index collisions for team ${this.teamId}… page ${page}`
|
||||
);
|
||||
|
||||
const collections = await Collection.unscoped().findAll({
|
||||
where: { teamId: this.teamId },
|
||||
attributes: ["id", "index"],
|
||||
limit,
|
||||
offset: page * limit,
|
||||
order: [
|
||||
Sequelize.literal('"collection"."index" collate "C"'), // ensure duplicates are in sequential order
|
||||
["updatedAt", "DESC"], // fallback as a tie breaker
|
||||
],
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!collections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
|
||||
while (idx < collections.length) {
|
||||
const collection = collections[idx];
|
||||
|
||||
if (collection.index === this.currDuplicateIndex) {
|
||||
// still in the same duplicate group.
|
||||
this.currDuplicateGroup.push(collection);
|
||||
} else {
|
||||
// current collection index is different from the previous one; resolve duplicates, if applicable.
|
||||
await this.resolveDuplicates({
|
||||
nextCollection: collection,
|
||||
transaction,
|
||||
});
|
||||
// reset the duplicate index and group.
|
||||
this.currDuplicateIndex = collection.index;
|
||||
this.currDuplicateGroup = [collection];
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
|
||||
return collections.length === limit
|
||||
? this.processPage(page + 1, transaction)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private async resolveDuplicates({
|
||||
nextCollection,
|
||||
transaction,
|
||||
}: {
|
||||
nextCollection?: Collection;
|
||||
transaction: Transaction;
|
||||
}) {
|
||||
if (this.currDuplicateGroup.length <= 1) {
|
||||
// no action needed when there aren't more than 1 item in a group.
|
||||
return;
|
||||
}
|
||||
|
||||
let prevIndex = this.currDuplicateGroup[0].index;
|
||||
const endIndex = nextCollection?.index ?? null;
|
||||
|
||||
// First collection in a duplicate group can retain its index.
|
||||
for (let idx = 1; idx < this.currDuplicateGroup.length; idx++) {
|
||||
const collection = this.currDuplicateGroup[idx];
|
||||
const newIndex = fractionalIndex(prevIndex, endIndex);
|
||||
|
||||
console.log(`New index for collection ${collection.id} = ${newIndex}`);
|
||||
|
||||
collection.index = newIndex;
|
||||
await collection.save({ silent: true, hooks: false, transaction });
|
||||
|
||||
prevIndex = newIndex;
|
||||
}
|
||||
|
||||
this.resolvedCollisionsCount += this.currDuplicateGroup.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function main(exit = false) {
|
||||
await Team.findAllInBatches<Team>({ batchLimit: 5 }, async (teams) => {
|
||||
for (const team of teams) {
|
||||
const resolver = new CollectionIndexCollisionResolver(team.id);
|
||||
await resolver.process();
|
||||
}
|
||||
});
|
||||
|
||||
if (exit) {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// In the test suite we import the script rather than run via node CLI
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
void main(true);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path";
|
||||
import { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import sequelizeStrictAttributes from "sequelize-strict-attributes";
|
||||
import { Sequelize } from "sequelize-typescript";
|
||||
import { Umzug, SequelizeStorage, MigrationError } from "umzug";
|
||||
import env from "@server/env";
|
||||
@@ -24,7 +23,7 @@ export function createDatabaseInstance(
|
||||
}
|
||||
): Sequelize {
|
||||
try {
|
||||
const instance = new Sequelize(databaseUrl, {
|
||||
return new Sequelize(databaseUrl, {
|
||||
logging: (msg) =>
|
||||
process.env.DEBUG?.includes("database") &&
|
||||
Logger.debug("database", msg),
|
||||
@@ -48,8 +47,6 @@ export function createDatabaseInstance(
|
||||
},
|
||||
schema,
|
||||
});
|
||||
sequelizeStrictAttributes(instance);
|
||||
return instance;
|
||||
} catch (error) {
|
||||
Logger.fatal(
|
||||
"Could not connect to database",
|
||||
|
||||
@@ -310,7 +310,7 @@ export async function buildCollection(
|
||||
overrides.permission = CollectionPermission.ReadWrite;
|
||||
}
|
||||
|
||||
return Collection.scope("withDocumentStructure").create({
|
||||
return Collection.create({
|
||||
name: faker.lorem.words(2),
|
||||
description: faker.lorem.words(4),
|
||||
createdById: overrides.userId,
|
||||
@@ -416,9 +416,7 @@ export async function buildDocument(
|
||||
|
||||
if (overrides.collectionId && overrides.publishedAt !== null) {
|
||||
collection = collection
|
||||
? await Collection.scope("withDocumentStructure").findByPk(
|
||||
overrides.collectionId
|
||||
)
|
||||
? await Collection.findByPk(overrides.collectionId)
|
||||
: undefined;
|
||||
|
||||
await collection?.addDocumentToStructure(document, 0);
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
"code": false,
|
||||
"color": "default"
|
||||
},
|
||||
"plain_text": "",
|
||||
"plain_text": "http://github.com/outline/",
|
||||
"href": "http://github.com/outline/"
|
||||
}
|
||||
],
|
||||
@@ -506,4 +506,4 @@
|
||||
"color": "default"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -11,6 +11,7 @@ export async function collectionIndexing(
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
attributes: ["id", "index", "name", "teamId"],
|
||||
transaction,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { GapCursor } from "prosemirror-gapcursor";
|
||||
import { Node, NodeType, Slice } from "prosemirror-model";
|
||||
import {
|
||||
Command,
|
||||
EditorState,
|
||||
TextSelection,
|
||||
Transaction,
|
||||
} from "prosemirror-state";
|
||||
import { Node, NodeType } from "prosemirror-model";
|
||||
import { Command, EditorState, TextSelection } from "prosemirror-state";
|
||||
import {
|
||||
CellSelection,
|
||||
addRow,
|
||||
@@ -16,16 +11,11 @@ import {
|
||||
addColumn,
|
||||
deleteRow,
|
||||
deleteColumn,
|
||||
deleteTable,
|
||||
} from "prosemirror-tables";
|
||||
import { ProsemirrorHelper } from "../../utils/ProsemirrorHelper";
|
||||
import { CSVHelper } from "../../utils/csv";
|
||||
import { chainTransactions } from "../lib/chainTransactions";
|
||||
import {
|
||||
getCellsInColumn,
|
||||
isHeaderEnabled,
|
||||
isTableSelected,
|
||||
} from "../queries/table";
|
||||
import { getCellsInColumn, isHeaderEnabled } from "../queries/table";
|
||||
import { TableLayout } from "../types";
|
||||
import { collapseSelection } from "./collapseSelection";
|
||||
|
||||
@@ -554,46 +544,3 @@ export function moveOutOfTable(direction: 1 | -1): Command {
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A command that deletes the entire table if all cells are selected.
|
||||
*
|
||||
* @returns The command
|
||||
*/
|
||||
export function deleteTableIfSelected(): Command {
|
||||
return (state, dispatch): boolean => {
|
||||
if (isTableSelected(state)) {
|
||||
return deleteTable(state, dispatch);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCellSelection(
|
||||
state: EditorState,
|
||||
dispatch?: (tr: Transaction) => void
|
||||
): boolean {
|
||||
const sel = state.selection;
|
||||
if (!(sel instanceof CellSelection)) {
|
||||
return false;
|
||||
}
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
const baseContent = tableNodeTypes(state.schema).cell.createAndFill()!
|
||||
.content;
|
||||
sel.forEachCell((cell, pos) => {
|
||||
if (!cell.content.eq(baseContent)) {
|
||||
tr.replace(
|
||||
tr.mapping.map(pos + 1),
|
||||
tr.mapping.map(pos + cell.nodeSize - 1),
|
||||
new Slice(baseContent, 0, 0)
|
||||
);
|
||||
}
|
||||
});
|
||||
if (tr.docChanged) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -198,12 +198,6 @@ export const codeLanguages: Record<string, CodeLanguage> = {
|
||||
label: "Powershell",
|
||||
loader: () => import("refractor/lang/powershell").then((m) => m.default),
|
||||
},
|
||||
promql: {
|
||||
lang: "promql",
|
||||
label: "PromQL",
|
||||
// @ts-expect-error PromQL is not in types but exists
|
||||
loader: () => import("refractor/lang/promql").then((m) => m.default),
|
||||
},
|
||||
protobuf: {
|
||||
lang: "protobuf",
|
||||
label: "Protobuf",
|
||||
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
mathSchemaSpec,
|
||||
} from "@benrbray/prosemirror-math";
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import {
|
||||
chainCommands,
|
||||
deleteSelection,
|
||||
selectNodeBackward,
|
||||
joinBackward,
|
||||
} from "prosemirror-commands";
|
||||
import {
|
||||
NodeSpec,
|
||||
NodeType,
|
||||
@@ -45,7 +51,12 @@ export default class Math extends Node {
|
||||
keys({ type }: { type: NodeType }) {
|
||||
return {
|
||||
"Mod-Space": insertMathCmd(type),
|
||||
Backspace: mathBackspaceCmd,
|
||||
Backspace: chainCommands(
|
||||
deleteSelection,
|
||||
mathBackspaceCmd,
|
||||
joinBackward,
|
||||
selectNodeBackward
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,8 @@ import {
|
||||
setTableAttr,
|
||||
deleteColSelection,
|
||||
deleteRowSelection,
|
||||
deleteCellSelection,
|
||||
moveOutOfTable,
|
||||
createTableInner,
|
||||
deleteTableIfSelected,
|
||||
} from "../commands/table";
|
||||
import { MarkdownSerializerState } from "../lib/markdown/serializer";
|
||||
import { FixTablesPlugin } from "../plugins/FixTables";
|
||||
@@ -98,16 +96,8 @@ export default class Table extends Node {
|
||||
"Shift-Tab": goToNextCell(-1),
|
||||
"Mod-Enter": addRowAndMoveSelection(),
|
||||
"Mod-Backspace": chainCommands(
|
||||
deleteCellSelection,
|
||||
deleteColSelection(),
|
||||
deleteRowSelection(),
|
||||
deleteTableIfSelected()
|
||||
),
|
||||
Backspace: chainCommands(
|
||||
deleteCellSelection,
|
||||
deleteColSelection(),
|
||||
deleteRowSelection(),
|
||||
deleteTableIfSelected()
|
||||
deleteRowSelection()
|
||||
),
|
||||
ArrowDown: moveOutOfTable(1),
|
||||
ArrowUp: moveOutOfTable(-1),
|
||||
|
||||
@@ -6,30 +6,6 @@ import {
|
||||
selectedRect,
|
||||
} from "prosemirror-tables";
|
||||
|
||||
/**
|
||||
* Checks if the current selection is a column selection.
|
||||
* @param state The editor state.
|
||||
* @returns True if the selection is a column selection, false otherwise.
|
||||
*/
|
||||
export function isColSelection(state: EditorState): boolean {
|
||||
if (state.selection instanceof CellSelection) {
|
||||
return state.selection.isColSelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current selection is a row selection.
|
||||
* @param state The editor state.
|
||||
* @returns True if the selection is a row selection, false otherwise.
|
||||
*/
|
||||
export function isRowSelection(state: EditorState): boolean {
|
||||
if (state.selection instanceof CellSelection) {
|
||||
return state.selection.isRowSelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getColumnIndex(state: EditorState): number | undefined {
|
||||
if (state.selection instanceof CellSelection) {
|
||||
if (state.selection.isColSelection()) {
|
||||
@@ -86,18 +62,13 @@ export function getCellsInRow(index: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific column is selected in the editor.
|
||||
*
|
||||
* @param state The editor state
|
||||
* @param index The index of the column to check
|
||||
* @returns Boolean indicating if the column is selected
|
||||
*/
|
||||
export function isColumnSelected(index: number) {
|
||||
return (state: EditorState): boolean => {
|
||||
if (isColSelection(state)) {
|
||||
const rect = selectedRect(state);
|
||||
return rect.left <= index && rect.right > index;
|
||||
if (state.selection instanceof CellSelection) {
|
||||
if (state.selection.isColSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
return rect.left <= index && rect.right > index;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -135,42 +106,28 @@ export function isHeaderEnabled(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific row is selected in the editor.
|
||||
*
|
||||
* @param state The editor state
|
||||
* @param index The index of the row to check
|
||||
* @returns Boolean indicating if the row is selected
|
||||
*/
|
||||
export function isRowSelected(index: number) {
|
||||
return (state: EditorState): boolean => {
|
||||
if (isRowSelection(state)) {
|
||||
const rect = selectedRect(state);
|
||||
return rect.top <= index && rect.bottom > index;
|
||||
if (state.selection instanceof CellSelection) {
|
||||
if (state.selection.isRowSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
return rect.top <= index && rect.bottom > index;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entire table is selected in the editor.
|
||||
*
|
||||
* @param state The editor state
|
||||
* @returns Boolean indicating if the table is selected
|
||||
*/
|
||||
export function isTableSelected(state: EditorState): boolean {
|
||||
if (state.selection instanceof CellSelection) {
|
||||
const rect = selectedRect(state);
|
||||
const rect = selectedRect(state);
|
||||
|
||||
return (
|
||||
rect.top === 0 &&
|
||||
rect.left === 0 &&
|
||||
rect.bottom === rect.map.height &&
|
||||
rect.right === rect.map.width &&
|
||||
!state.selection.empty
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
return (
|
||||
rect.top === 0 &&
|
||||
rect.left === 0 &&
|
||||
rect.bottom === rect.map.height &&
|
||||
rect.right === rect.map.width &&
|
||||
!state.selection.empty &&
|
||||
state.selection instanceof CellSelection
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,6 +172,8 @@
|
||||
"Deleting": "Deleting",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however all published documents within will be moved to the trash.",
|
||||
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||
"Add a description": "Add a description",
|
||||
"Type a command or search": "Type a command or search",
|
||||
"Choose a template": "Choose a template",
|
||||
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
|
||||
@@ -616,8 +618,6 @@
|
||||
"{{ groupsCount }} groups with access": "{{ groupsCount }} group with access",
|
||||
"{{ groupsCount }} groups with access_plural": "{{ groupsCount }} groups with access",
|
||||
"Archived by {{userName}}": "Archived by {{userName}}",
|
||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||
"Add a description": "Add a description",
|
||||
"Share": "Share",
|
||||
"Overview": "Overview",
|
||||
"Recently updated": "Recently updated",
|
||||
|
||||
@@ -7,16 +7,6 @@ import {
|
||||
faWebAwesome,
|
||||
faXTwitter,
|
||||
faBluesky,
|
||||
faGithub,
|
||||
faGitlab,
|
||||
faDiscord,
|
||||
faDocker,
|
||||
faCodepen,
|
||||
faDropbox,
|
||||
faPaypal,
|
||||
faShopify,
|
||||
faSwift,
|
||||
faSlack,
|
||||
} from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faBagShopping,
|
||||
@@ -561,16 +551,6 @@ export class IconLibrary {
|
||||
faPython,
|
||||
faXTwitter,
|
||||
faBluesky,
|
||||
faGithub,
|
||||
faGitlab,
|
||||
faDiscord,
|
||||
faDocker,
|
||||
faCodepen,
|
||||
faDropbox,
|
||||
faPaypal,
|
||||
faShopify,
|
||||
faSwift,
|
||||
faSlack,
|
||||
].map((icon) => [
|
||||
icon.iconName,
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user