mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
Compare commits
2 Commits
main
...
fix-notion
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d9f0a458 | |||
| f99d8c2808 |
@@ -1,7 +1,7 @@
|
||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react/menus";
|
||||
import { isNodeSelection, useEditorState } 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 {
|
||||
IconBold,
|
||||
IconCode,
|
||||
@@ -49,6 +49,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const showCommentPopupRef = useRef(showCommentPopup);
|
||||
const showAiMenuRef = useRef(showAiMenu);
|
||||
const isLinkSelectorOpenRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
showCommentPopupRef.current = showCommentPopup;
|
||||
@@ -125,6 +126,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
if (isLinkSelectorOpenRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { selection } = state;
|
||||
const { empty } = selection;
|
||||
|
||||
@@ -155,7 +160,14 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = 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);
|
||||
|
||||
// Hide the bubble menu immediately when AI menu is shown
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildAttachmentCandidates,
|
||||
collectMarkdownAndHtmlFiles,
|
||||
encodeFilePath,
|
||||
extractNotionPartialId,
|
||||
readDocmostMetadata,
|
||||
stripNotionID,
|
||||
} from '../utils/import.utils';
|
||||
@@ -160,6 +161,7 @@ export class FileImportTaskService {
|
||||
fileTask: FileTask;
|
||||
}): Promise<void> {
|
||||
const { extractDir, fileTask } = opts;
|
||||
const isNotion = fileTask.source === FileImportSource.Notion;
|
||||
const allFiles = await collectMarkdownAndHtmlFiles(extractDir);
|
||||
const attachmentCandidates = await buildAttachmentCandidates(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
|
||||
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 (
|
||||
skipRootFolder &&
|
||||
folderPath?.toLowerCase() === skipRootFolder?.toLowerCase()
|
||||
@@ -243,18 +255,54 @@ export class FileImportTaskService {
|
||||
|
||||
if (!pagesMap.has(mdPath) && !pagesMap.has(htmlPath)) {
|
||||
const folderName = path.basename(folderPath);
|
||||
const encodedMdPath = encodeFilePath(mdPath);
|
||||
const placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||
pagesMap.set(mdPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: stripNotionID(folderName),
|
||||
content: '',
|
||||
parentPageId: null,
|
||||
fileExtension: '.md',
|
||||
filePath: mdPath,
|
||||
icon: placeholderMetadata?.icon ?? null,
|
||||
});
|
||||
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 placeholderMetadata = docmostMetadata?.pages[encodedMdPath];
|
||||
pagesMap.set(mdPath, {
|
||||
id: v7(),
|
||||
slugId: generateSlugId(),
|
||||
name: stripNotionID(folderName),
|
||||
content: '',
|
||||
parentPageId: null,
|
||||
fileExtension: '.md',
|
||||
filePath: mdPath,
|
||||
icon: placeholderMetadata?.icon ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getEmbedUrlAndProvider } from '@docmost/editor-ext';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as path from 'path';
|
||||
import { v7 } from 'uuid';
|
||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -344,14 +345,35 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
const meta = filePathToPageMetaMap.get(resolved);
|
||||
if (!meta) return;
|
||||
|
||||
const titleSlug = slugify(meta.title?.substring(0, 70) || 'untitled');
|
||||
const pageSlug = `${titleSlug}-${meta.slugId}`;
|
||||
const internalHref = spaceSlug
|
||||
? `/s/${spaceSlug}/p/${pageSlug}`
|
||||
: `/p/${pageSlug}`;
|
||||
const linkText = $a.text().trim();
|
||||
const titleMatch =
|
||||
linkText === meta.title ||
|
||||
linkText === meta.title?.trim();
|
||||
|
||||
$a.attr('href', internalHref);
|
||||
$a.attr('data-internal', 'true');
|
||||
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 pageSlug = `${titleSlug}-${meta.slugId}`;
|
||||
const internalHref = spaceSlug
|
||||
? `/s/${spaceSlug}/p/${pageSlug}`
|
||||
: `/p/${pageSlug}`;
|
||||
|
||||
$a.attr('href', internalHref);
|
||||
$a.attr('data-internal', 'true');
|
||||
}
|
||||
|
||||
backlinks.push({ sourcePageId, targetPageId: meta.id, workspaceId });
|
||||
});
|
||||
|
||||
@@ -81,7 +81,25 @@ export async function collectMarkdownAndHtmlFiles(
|
||||
export function stripNotionID(fileName: string): string {
|
||||
// Handle optional separator (space or dash) + 32 alphanumeric chars at end
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user