feat: collection mentions (#8529)

* feat: init collection mention

* refactor: dedicated search helper function for collection mentions

* feat: add test for collection search function helper

* feat: parseCollectionSlug

* feat: isCollectionUrl

* feat: add collection mention to paste handler

* fix: update translation of mention keyboard shortcut

* fix: keyboard shortcut mention label

* fix: missing teamId in search helper functioN

* chore: update translations

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
YouLL
2025-03-04 04:03:27 +01:00
committed by GitHub
parent 2a3ea1254c
commit d551a1a10b
39 changed files with 371 additions and 62 deletions
+2
View File
@@ -2,6 +2,8 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const CollectionsSection = ({ t }: ActionContext) => t("Collections");
export const ActiveCollectionSection = ({ t, stores }: ActionContext) => {
const activeCollection = stores.collections.active;
return `${t("Collection")} · ${activeCollection?.name}`;
+41 -4
View File
@@ -1,6 +1,6 @@
import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { DocumentIcon, PlusIcon } from "outline-icons";
import { DocumentIcon, PlusIcon, CollectionIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
@@ -12,7 +12,11 @@ import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import Flex from "~/components/Flex";
import { DocumentsSection, UserSection } from "~/actions/sections";
import {
DocumentsSection,
UserSection,
CollectionsSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
@@ -40,7 +44,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = React.useState(false);
const [items, setItems] = React.useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users } = useStores();
const { auth, documents, users, collections } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -49,8 +53,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const { loading, request } = useRequest(
React.useCallback(async () => {
const res = await client.post("/suggestions.mention", { query: search });
res.data.documents.map(documents.add);
res.data.users.map(users.add);
res.data.collections.map(collections.add);
}, [search, documents, users])
);
@@ -119,6 +125,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
} as MentionItem)
)
)
.concat(
collections
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
(collection) =>
({
name: "mention",
icon: collection.icon ? (
<Icon
value={collection.icon}
color={collection.color ?? undefined}
/>
) : (
<CollectionIcon />
),
title: collection.name,
section: CollectionsSection,
appendSpace: true,
attrs: {
id: v4(),
type: MentionType.Collection,
modelId: collection.id,
actorId,
label: collection.name,
},
} as MentionItem)
)
)
.concat([
{
name: "link",
@@ -146,7 +180,10 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const handleSelect = React.useCallback(
async (item: MentionItem) => {
if (item.attrs.type === MentionType.Document) {
if (
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
return;
}
if (!documentId) {
+47 -1
View File
@@ -20,8 +20,9 @@ import { isInCode } from "@shared/editor/queries/isInCode";
import { MenuItem } from "@shared/editor/types";
import { IconType, MentionType } from "@shared/types";
import { determineIconType } from "@shared/utils/icon";
import parseCollectionSlug from "@shared/utils/parseCollectionSlug";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import { isCollectionUrl, isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
import PasteMenu from "../components/PasteMenu";
@@ -166,6 +167,51 @@ export default class PasteHandler extends Extension {
this.insertLink(text);
});
}
} else if (isCollectionUrl(text)) {
const slug = parseCollectionSlug(text);
if (slug) {
stores.collections
.fetch(slug)
.then((collection) => {
if (view.isDestroyed) {
return;
}
if (collection) {
if (state.schema.nodes.mention) {
view.dispatch(
view.state.tr.replaceWith(
state.selection.from,
state.selection.to,
state.schema.nodes.mention.create({
type: MentionType.Collection,
modelId: collection.id,
label: collection.name,
id: v4(),
})
)
);
} else {
const { hash } = new URL(text);
const hasEmoji =
determineIconType(collection.icon) ===
IconType.Emoji;
const title = `${
hasEmoji ? collection.icon + " " : ""
}${collection.name}`;
this.insertLink(`${collection.path}${hash}`, title);
}
}
})
.catch(() => {
if (view.isDestroyed) {
return;
}
this.insertLink(text);
});
}
} else {
this.insertLink(text);
}
+5
View File
@@ -92,6 +92,11 @@ export default class Collection extends ParanoidModel {
@observable
archivedBy?: User;
@computed
get searchContent(): string {
return this.name;
}
/** Returns whether the collection is empty, or undefined if not loaded. */
@computed
get isEmpty(): boolean | undefined {
+1 -1
View File
@@ -462,7 +462,7 @@ function KeyboardShortcuts() {
items: [
{
shortcut: "@",
label: t("Mention user or document"),
label: t("Mention users and more"),
},
{
shortcut: ":",
@@ -861,6 +861,51 @@ describe("SearchHelper", () => {
});
});
describe("#searchCollectionsForUser", () => {
test("should return search results from collections", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Test Collection",
});
await buildCollection({
teamId: team.id,
userId: user.id,
name: "Other Collection",
});
const results = await SearchHelper.searchCollectionsForUser(user, {
query: "test",
});
expect(results.length).toBe(1);
expect(results[0].id).toBe(collection1.id);
});
test("should return all collections when no query provided", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const collection1 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Alpha",
});
const collection2 = await buildCollection({
teamId: team.id,
userId: user.id,
name: "Beta",
});
const results = await SearchHelper.searchCollectionsForUser(user);
expect(results.length).toBe(2);
expect(results[0].id).toBe(collection1.id);
expect(results[1].id).toBe(collection2.id);
});
});
describe("webSearchQuery", () => {
test("should correctly sanitize query", () => {
expect(SearchHelper.webSearchQuery("one/two")).toBe("one/two:*");
+29
View File
@@ -203,6 +203,35 @@ export default class SearchHelper {
});
}
public static async searchCollectionsForUser(
user: User,
options: SearchOptions = {}
): Promise<Collection[]> {
const { limit = 15, offset = 0, query } = options;
const collectionIds = await user.collectionIds();
return Collection.findAll({
where: {
[Op.and]: query
? {
[Op.or]: [
Sequelize.literal(
`unaccent(LOWER(name)) like unaccent(LOWER(:query))`
),
],
}
: {},
id: collectionIds,
teamId: user.teamId,
},
order: [["name", "ASC"]],
replacements: { query: `%${query}%` },
limit,
offset,
});
}
public static async searchForUser(
user: User,
options: SearchOptions = {}
+3 -1
View File
@@ -23,7 +23,7 @@ router.post(
const { offset, limit } = ctx.state.pagination;
const actor = ctx.state.auth.user;
const [documents, users] = await Promise.all([
const [documents, users, collections] = await Promise.all([
SearchHelper.searchTitlesForUser(actor, {
query,
offset,
@@ -53,6 +53,7 @@ router.post(
offset,
limit,
}),
SearchHelper.searchCollectionsForUser(actor, { query, offset, limit }),
]);
ctx.body = {
@@ -67,6 +68,7 @@ router.post(
includeDetails: !!can(actor, "readDetails", user),
})
),
collections,
},
};
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { DocumentIcon, EmailIcon } from "outline-icons";
import { DocumentIcon, EmailIcon, CollectionIcon } from "outline-icons";
import { Node } from "prosemirror-model";
import * as React from "react";
import { Link } from "react-router-dom";
@@ -67,3 +67,36 @@ export const MentionDocument = observer(function MentionDocument_(
</Link>
);
});
export const MentionCollection = observer(function MentionCollection_(
props: ComponentProps
) {
const { isSelected, node } = props;
const { collections } = useStores();
const collection = collections.get(node.attrs.modelId);
const modelId = node.attrs.modelId;
const { className, ...attrs } = getAttributesFromNode(node);
React.useEffect(() => {
if (modelId) {
void collections.fetch(modelId);
}
}, [modelId, collections]);
return (
<Link
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
to={collection?.path ?? `/collection/${node.attrs.modelId}`}
>
{collection?.icon ? (
<Icon value={collection?.icon} color={collection?.color} size={18} />
) : (
<CollectionIcon size={18} />
)}
{collection?.title || node.attrs.label}
</Link>
);
});
+25 -4
View File
@@ -16,7 +16,11 @@ import { Primitive } from "utility-types";
import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import { MentionType } from "../../types";
import { MentionDocument, MentionUser } from "../components/Mentions";
import {
MentionCollection,
MentionDocument,
MentionUser,
} from "../components/Mentions";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import mentionRule from "../rules/mention";
import { ComponentProps } from "../types";
@@ -76,7 +80,9 @@ export default class Mention extends Node {
href:
node.attrs.type === MentionType.User
? undefined
: `${env.URL}/doc/${node.attrs.modelId}`,
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
: `${env.URL}/collection/${node.attrs.modelId}`,
"data-type": node.attrs.type,
"data-id": node.attrs.modelId,
"data-actorid": node.attrs.actorId,
@@ -97,6 +103,8 @@ export default class Mention extends Node {
return <MentionUser {...props} />;
case MentionType.Document:
return <MentionDocument {...props} />;
case MentionType.Collection:
return <MentionCollection {...props} />;
default:
return null;
}
@@ -145,10 +153,23 @@ export default class Mention extends Node {
if (
selection instanceof NodeSelection &&
selection.node.type.name === this.name &&
selection.node.attrs.type === MentionType.Document
(selection.node.attrs.type === MentionType.Document ||
selection.node.attrs.type === MentionType.Collection)
) {
const { modelId } = selection.node.attrs;
this.editor.props.onClickLink?.(`/doc/${modelId}`);
const linkType =
selection.node.attrs.type === MentionType.Document
? "doc"
: selection.node.attrs.type === MentionType.Collection
? "collection"
: undefined;
if (!linkType) {
return false;
}
this.editor.props.onClickLink?.(`/${linkType}/${modelId}`);
return true;
}
return false;
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Vložený kód",
"Inline LaTeX": "Vložený LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Přihlásit se",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Vytvořili jste před {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} vytvořil před {{ timeAgo }}",
"Uploading": "Nahrávání"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Inline-Code",
"Inline LaTeX": "Inline-LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Anmelden",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Du hast vor {{ timeAgo }} erstellt",
"{{ user }} created {{ timeAgo }}": "{{ user }} erstellte vor {{ timeAgo }}",
"Uploading": "Wird hochgeladen"
}
}
+2 -2
View File
@@ -137,6 +137,7 @@
"Update role": "Update role",
"Delete user": "Delete user",
"Collection": "Collection",
"Collections": "Collections",
"Debug": "Debug",
"Document": "Document",
"Documents": "Documents",
@@ -368,7 +369,6 @@
"Archived collections": "Archived collections",
"New doc": "New doc",
"Empty": "Empty",
"Collections": "Collections",
"Collapse": "Collapse",
"Expand": "Expand",
"Document not supported try Markdown, Plain text, HTML, or Word": "Document not supported try Markdown, Plain text, HTML, or Word",
@@ -770,7 +770,7 @@
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Código en línea",
"Inline LaTeX": "Línea de LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Iniciar sesión",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Tú lo creaste {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} lo creó {{ timeAgo }}",
"Uploading": "Subiendo"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "کد درون خطی",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "ورود",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "در حال بارگذاری"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Ligne de Code",
"Inline LaTeX": "LaTeX en ligne",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Se connecter",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Créé par vous il y a {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "Créé par {{ user }} il y a {{ timeAgo }}",
"Uploading": "Transfert en cours"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Beágyazott kód",
"Inline LaTeX": "Beágyazott LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Bejelentkezés",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Kode inline",
"Inline LaTeX": "LaTeX Inline",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Masuk",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Mengunggah"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Codice inline",
"Inline LaTeX": "LaTeX in linea",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Accedi",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Caricamento"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "インラインコード",
"Inline LaTeX": "インライン LaTeX",
"Triggers": "トリガー",
"Mention user or document": "ユーザーまたはドキュメントにメンション",
"Mention users and more": "ユーザーまたはドキュメントにメンション",
"Emoji": "絵文字",
"Insert block": "ブロックの挿入",
"Sign In": "ログイン",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "あなたが {{ timeAgo }} に作成しました",
"{{ user }} created {{ timeAgo }}": "{{ user }} が {{ timeAgo }} に作成しました",
"Uploading": "アップロード中"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "인라인 코드",
"Inline LaTeX": "인라인 LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "로그인",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "{{ timeAgo }} 전에 내가 생성함",
"{{ user }} created {{ timeAgo }}": "{{ user }} 이(가) {{ timeAgo }} 전에 생성",
"Uploading": "업로드 중"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Innebygd kode",
"Inline LaTeX": "Innebygd LaTeX",
"Triggers": "Utløsere",
"Mention user or document": "Nevn bruker eller dokument",
"Mention users and more": "Nevn bruker eller dokument",
"Emoji": "Emoji",
"Insert block": "Sett inn blokk",
"Sign In": "Logg inn",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Du opprettet {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} opprettet {{ timeAgo }}",
"Uploading": "Laster opp"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Voeg blok in",
"Sign In": "Aanmelden",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "{{ timeAgo }} door jou aangemaakt",
"{{ user }} created {{ timeAgo }}": "{{ timeAgo }} door {{ user }} aangemaakt",
"Uploading": "Bezig met uploaden"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Kod w linii",
"Inline LaTeX": "LaTeX w linii",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Zaloguj się",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Utworzyłeś {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} utworzył {{ timeAgo }}",
"Uploading": "Wysyłanie"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Código embutido",
"Inline LaTeX": "LaTeX em linha",
"Triggers": "Gatilhos",
"Mention user or document": "Mencionar usuário ou documento",
"Mention users and more": "Mencionar usuário ou documento",
"Emoji": "Emoji",
"Insert block": "Inserir bloco",
"Sign In": "Entrar",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Você criou {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} criou {{ timeAgo }}",
"Uploading": "Enviando"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Código em linha",
"Inline LaTeX": "LaTeX Embutido",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Iniciar Sessão",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Criou à {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} criado à {{ timeAgo }}",
"Uploading": "A carregar"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Inline-kod",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Logga in",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Du skapade {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} skapade {{ timeAgo }}",
"Uploading": "Laddar upp"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Inline code",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Sign In",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Uploading"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Satır içi kod",
"Inline LaTeX": "Inline LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Kayıt ol",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Yükleniyor"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Вбудований код",
"Inline LaTeX": "Вбудований LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Увійти",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "Ви створили {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} створив {{ timeAgo }}",
"Uploading": "Завантажується"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "Mã nội tuyến",
"Inline LaTeX": "LaTeX nội tuyến",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "Đăng nhập",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "You created {{ timeAgo }}",
"{{ user }} created {{ timeAgo }}": "{{ user }} created {{ timeAgo }}",
"Uploading": "Đang tải lên"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "行内代码",
"Inline LaTeX": "行内公式",
"Triggers": "Triggers",
"Mention user or document": "提及用户或文档",
"Mention users and more": "提及用户或文档",
"Emoji": "表情",
"Insert block": "Insert block",
"Sign In": "登录",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "{{ timeAgo }} 由你创建",
"{{ user }} created {{ timeAgo }}": "{{ timeAgo }} 由 {{ user }} 创建",
"Uploading": "上传中"
}
}
+2 -2
View File
@@ -769,7 +769,7 @@
"Inline code": "行內程式碼",
"Inline LaTeX": "行內 LaTeX",
"Triggers": "Triggers",
"Mention user or document": "Mention user or document",
"Mention users and more": "Mention users and more",
"Emoji": "Emoji",
"Insert block": "Insert block",
"Sign In": "登入",
@@ -1143,4 +1143,4 @@
"You created {{ timeAgo }}": "{{ timeAgo }} 由您新增",
"{{ user }} created {{ timeAgo }}": "{{ timeAgo }} 由 {{ user }} 新增",
"Uploading": "正在上傳"
}
}
+1
View File
@@ -57,6 +57,7 @@ export enum FileOperationState {
export enum MentionType {
User = "user",
Document = "document",
Collection = "collection",
}
export type PublicEnv = {
+48
View File
@@ -0,0 +1,48 @@
import sharedEnv from "../env";
import parseCollectionSlug from "./parseCollectionSlug";
sharedEnv.URL = "https://app.outline.dev";
describe("#parseCollectionSlug", () => {
it("should work with fully qualified url", () => {
expect(
parseCollectionSlug("http://example.com/collection/test-ANzZwgv2RG")
).toEqual("test-ANzZwgv2RG");
});
it("should work with paths after document slug", () => {
expect(
parseCollectionSlug(
"http://mywiki.getoutline.com/collection/test-ANzZwgv2RG/recent"
)
).toEqual("test-ANzZwgv2RG");
});
it("should work with hash", () => {
expect(
parseCollectionSlug(
"http://mywiki.getoutline.com/collection/test-ANzZwgv2RG#state"
)
).toEqual("test-ANzZwgv2RG");
});
it("should work with subdomain qualified url", () => {
expect(
parseCollectionSlug(
"http://mywiki.getoutline.com/collection/test-ANzZwgv2RG"
)
).toEqual("test-ANzZwgv2RG");
});
it("should work with path", () => {
expect(parseCollectionSlug("/collection/test-ANzZwgv2RG")).toEqual(
"test-ANzZwgv2RG"
);
});
it("should work with path and hash", () => {
expect(parseCollectionSlug("/collection/test-ANzZwgv2RG#somehash")).toEqual(
"test-ANzZwgv2RG"
);
});
});
+25
View File
@@ -0,0 +1,25 @@
import sharedEnv from "../env";
/**
* Parse the likely collection identifier from a given url.
*
* @param url The url to parse.
* @returns A collection identifier or undefined if not found.
*/
export default function parseCollectionSlug(url: string) {
let parsed;
if (url[0] === "/") {
url = `${sharedEnv.URL}${url}`;
}
try {
parsed = new URL(url).pathname;
} catch (err) {
return;
}
const split = parsed.split("/");
const indexOfCollection = split.indexOf("collection");
return split[indexOfCollection + 1] ?? undefined;
}
+15
View File
@@ -76,6 +76,21 @@ export function isDocumentUrl(url: string) {
}
}
/**
* Returns true if the given string is a link to a collection.
*
* @param options Parsing options.
* @returns True if a collection, false otherwise.
*/
export function isCollectionUrl(url: string) {
try {
const parsed = new URL(url, env.URL);
return isInternalUrl(url) && parsed.pathname.startsWith("/collection/");
} catch (err) {
return false;
}
}
/**
* Returns true if the given string is a url.
*