mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
fix notion importer (#2027)
* fix notion importer * fix link selector on mobile
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||||
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
import { isNodeSelection, useEditorState } from "@tiptap/react";
|
||||||
import type { Editor } from "@tiptap/react";
|
import type { Editor } from "@tiptap/react";
|
||||||
import { FC, useEffect, useRef, useState } from "react";
|
import { FC, SetStateAction, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
IconBold,
|
IconBold,
|
||||||
IconCode,
|
IconCode,
|
||||||
@@ -49,6 +49,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||||
const showCommentPopupRef = useRef(showCommentPopup);
|
const showCommentPopupRef = useRef(showCommentPopup);
|
||||||
const showAiMenuRef = useRef(showAiMenu);
|
const showAiMenuRef = useRef(showAiMenu);
|
||||||
|
const isLinkSelectorOpenRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showCommentPopupRef.current = showCommentPopup;
|
showCommentPopupRef.current = showCommentPopup;
|
||||||
@@ -125,6 +126,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
...props,
|
...props,
|
||||||
shouldShow: ({ state, editor }) => {
|
shouldShow: ({ state, editor }) => {
|
||||||
|
if (isLinkSelectorOpenRef.current) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const { selection } = state;
|
const { selection } = state;
|
||||||
const { empty } = selection;
|
const { empty } = selection;
|
||||||
|
|
||||||
@@ -155,7 +160,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
|||||||
|
|
||||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
const [isTextAlignmentSelectorOpen, setIsTextAlignmentOpen] = useState(false);
|
||||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
const [isLinkSelectorOpen, _setIsLinkSelectorOpen] = useState(false);
|
||||||
|
const setIsLinkSelectorOpen = useCallback((value: SetStateAction<boolean>) => {
|
||||||
|
_setIsLinkSelectorOpen((prev) => {
|
||||||
|
const next = typeof value === 'function' ? value(prev) : value;
|
||||||
|
isLinkSelectorOpenRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||||
|
|
||||||
// Hide the bubble menu immediately when AI menu is shown
|
// Hide the bubble menu immediately when AI menu is shown
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
buildAttachmentCandidates,
|
buildAttachmentCandidates,
|
||||||
collectMarkdownAndHtmlFiles,
|
collectMarkdownAndHtmlFiles,
|
||||||
encodeFilePath,
|
encodeFilePath,
|
||||||
|
extractNotionPartialId,
|
||||||
readDocmostMetadata,
|
readDocmostMetadata,
|
||||||
stripNotionID,
|
stripNotionID,
|
||||||
} from '../utils/import.utils';
|
} from '../utils/import.utils';
|
||||||
@@ -160,6 +161,7 @@ export class FileImportTaskService {
|
|||||||
fileTask: FileTask;
|
fileTask: FileTask;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { extractDir, fileTask } = opts;
|
const { extractDir, fileTask } = opts;
|
||||||
|
const isNotion = fileTask.source === FileImportSource.Notion;
|
||||||
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
|
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
|
||||||
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
|
const attachmentCandidates = await buildAttachmentCandidates(extractDir);
|
||||||
const docmostMetadata = await readDocmostMetadata(extractDir);
|
const docmostMetadata = await readDocmostMetadata(extractDir);
|
||||||
@@ -230,7 +232,17 @@ export class FileImportTaskService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
|
// For each folder with content, create a placeholder page if no corresponding .md or .html exists
|
||||||
foldersWithContent.forEach((folderPath) => {
|
// Process folders with partial UUIDs first so they claim their specific files
|
||||||
|
// before plain folders (without partial UUIDs) take whatever remains.
|
||||||
|
const sortedFolders = isNotion
|
||||||
|
? [...foldersWithContent].sort((a, b) => {
|
||||||
|
const aHasPartial = extractNotionPartialId(path.basename(a)) ? 0 : 1;
|
||||||
|
const bHasPartial = extractNotionPartialId(path.basename(b)) ? 0 : 1;
|
||||||
|
return aHasPartial - bHasPartial;
|
||||||
|
})
|
||||||
|
: [...foldersWithContent];
|
||||||
|
|
||||||
|
sortedFolders.forEach((folderPath) => {
|
||||||
if (
|
if (
|
||||||
skipRootFolder &&
|
skipRootFolder &&
|
||||||
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
|
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
|
||||||
@@ -243,6 +255,41 @@ export class FileImportTaskService {
|
|||||||
|
|
||||||
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
|
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
|
||||||
const folderName = path.basename(folderPath);
|
const folderName = path.basename(folderPath);
|
||||||
|
const parentDir = path.dirname(folderPath);
|
||||||
|
|
||||||
|
// Notion no longer adds UUIDs to folder names, but still adds them to files.
|
||||||
|
// For duplicate names, Notion adds a partial UUID "{first4}-{last4}" to the folder.
|
||||||
|
let matched = false;
|
||||||
|
if (isNotion) {
|
||||||
|
const partialId = extractNotionPartialId(folderName);
|
||||||
|
const strippedFolderName = stripNotionID(folderName);
|
||||||
|
const isSameDir = (fileDir: string) =>
|
||||||
|
fileDir === parentDir || (parentDir === '.' && !fileDir.includes('/'));
|
||||||
|
|
||||||
|
for (const [filePath, page] of pagesMap.entries()) {
|
||||||
|
if (!isSameDir(path.dirname(filePath))) continue;
|
||||||
|
if (page.name !== strippedFolderName) continue;
|
||||||
|
|
||||||
|
if (partialId) {
|
||||||
|
// Match partial UUID against the full UUID in the filename
|
||||||
|
const fileBase = path.basename(filePath, path.extname(filePath));
|
||||||
|
const fullIdMatch = fileBase.match(/[a-f0-9]{32}$/i);
|
||||||
|
if (!fullIdMatch) continue;
|
||||||
|
const fullId = fullIdMatch[0].toLowerCase();
|
||||||
|
if (!fullId.startsWith(partialId.prefix) || !fullId.endsWith(partialId.suffix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesMap.delete(filePath);
|
||||||
|
page.filePath = mdPath;
|
||||||
|
pagesMap.set(mdPath, page);
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
const encodedMdPath = encodeFilePath(mdPath);
|
const encodedMdPath = encodeFilePath(mdPath);
|
||||||
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||||
pagesMap.set(mdPath, {
|
pagesMap.set(mdPath, {
|
||||||
@@ -256,6 +303,7 @@ export class FileImportTaskService {
|
|||||||
icon: placeholderMetadata?.icon ?? null,
|
icon: placeholderMetadata?.icon ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// parent/child linking
|
// parent/child linking
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
|
import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { v7 } from 'uuid';
|
||||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
@@ -344,6 +345,26 @@ export async function rewriteInternalLinksToMentionHtml(
|
|||||||
const meta = filePathToPageMetaMap.get(resolved);
|
const meta = filePathToPageMetaMap.get(resolved);
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
|
||||||
|
const linkText = $a.text().trim();
|
||||||
|
const titleMatch =
|
||||||
|
linkText === meta.title ||
|
||||||
|
linkText === meta.title?.trim();
|
||||||
|
|
||||||
|
if (titleMatch) {
|
||||||
|
const mentionId = v7();
|
||||||
|
const $mention = $('<span>')
|
||||||
|
.attr({
|
||||||
|
'data-type': 'mention',
|
||||||
|
'data-id': mentionId,
|
||||||
|
'data-entity-type': 'page',
|
||||||
|
'data-entity-id': meta.id,
|
||||||
|
'data-label': meta.title,
|
||||||
|
'data-slug-id': meta.slugId,
|
||||||
|
'data-creator-id': creatorId,
|
||||||
|
})
|
||||||
|
.text(meta.title);
|
||||||
|
$a.replaceWith($mention);
|
||||||
|
} else {
|
||||||
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
|
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
|
||||||
const pageSlug = `${titleSlug}-${meta.slugId}`;
|
const pageSlug = `${titleSlug}-${meta.slugId}`;
|
||||||
const internalHref = spaceSlug
|
const internalHref = spaceSlug
|
||||||
@@ -352,6 +373,7 @@ export async function rewriteInternalLinksToMentionHtml(
|
|||||||
|
|
||||||
$a.attr('href', internalHref);
|
$a.attr('href', internalHref);
|
||||||
$a.attr('data-internal', 'true');
|
$a.attr('data-internal', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
|
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,7 +81,25 @@ export async function collectMarkdownAndHtmlFiles(
|
|||||||
export function stripNotionID(fileName: string): string {
|
export function stripNotionID(fileName: string): string {
|
||||||
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
|
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
|
||||||
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
|
const notionIdPattern = /[ -]?[a-z0-9]{32}$/i;
|
||||||
return fileName.replace(notionIdPattern, '').trim();
|
// Handle partial UUID format used for duplicate names: "Name abcd-ef12"
|
||||||
|
const partialIdPattern = / [a-f0-9]{4}-[a-f0-9]{4}$/i;
|
||||||
|
return fileName
|
||||||
|
.replace(notionIdPattern, '')
|
||||||
|
.replace(partialIdPattern, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a partial Notion UUID suffix from a folder name.
|
||||||
|
* Notion adds "{first4}-{last4}" when multiple pages share the same title.
|
||||||
|
* e.g. "Cool 324d-35ab" → { prefix: "324d", suffix: "35ab" }
|
||||||
|
*/
|
||||||
|
export function extractNotionPartialId(
|
||||||
|
folderName: string,
|
||||||
|
): { prefix: string; suffix: string } | null {
|
||||||
|
const match = folderName.match(/ ([a-f0-9]{4})-([a-f0-9]{4})$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
return { prefix: match[1].toLowerCase(), suffix: match[2].toLowerCase() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encodeFilePath(filePath: string): string {
|
export function encodeFilePath(filePath: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user