Compare commits

...

2 Commits

Author SHA1 Message Date
Philipinho 33d9f0a458 fix link selector on mobile 2026-03-15 22:02:11 +00:00
Philipinho f99d8c2808 fix notion importer 2026-03-15 21:49:06 +00:00
4 changed files with 123 additions and 23 deletions
@@ -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 {