mirror of
https://github.com/docmost/docmost.git
synced 2026-05-17 06:44:05 +08:00
Merge branch 'main' into chat
This commit is contained in:
@@ -266,6 +266,12 @@ export class EnvironmentService {
|
||||
);
|
||||
}
|
||||
|
||||
getAiEmbeddingSupportsMrl(): boolean | undefined {
|
||||
const val = this.configService.get<string>('AI_EMBEDDING_SUPPORTS_MRL');
|
||||
if (val === undefined || val === null || val === '') return undefined;
|
||||
return val === 'true';
|
||||
}
|
||||
|
||||
getOpenAiApiKey(): string {
|
||||
return this.configService.get<string>('OPENAI_API_KEY');
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
validateSync,
|
||||
} from 'class-validator';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { IsISO6391 } from '../../common/validator/is-iso6391';
|
||||
import { IsISO6391 } from '../../common/validators/is-iso6391';
|
||||
|
||||
export class EnvironmentVariables {
|
||||
@IsNotEmpty()
|
||||
@@ -117,6 +117,12 @@ export class EnvironmentVariables {
|
||||
@IsString()
|
||||
AI_EMBEDDING_DIMENSION: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.AI_EMBEDDING_SUPPORTS_MRL)
|
||||
@IsIn(['true', 'false'])
|
||||
@IsString()
|
||||
AI_EMBEDDING_SUPPORTS_MRL: string;
|
||||
|
||||
@ValidateIf((obj) => obj.AI_DRIVER)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -25,4 +25,75 @@ export class LicenseCheckService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
|
||||
if (this.environmentService.isCloud()) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||
return getFeaturesForCloudPlan(plan).has(feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.hasFeature(licenseKey, feature);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getFeatures(licenseKey: string): string[] {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.getFeatures(licenseKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
resolveFeatures(licenseKey: string, plan: string): string[] {
|
||||
if (this.environmentService.isCloud()) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||
return [...getFeaturesForCloudPlan(plan)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return this.getFeatures(licenseKey);
|
||||
}
|
||||
|
||||
resolveTier(licenseKey: string, plan: string): string {
|
||||
if (this.environmentService.isCloud()) {
|
||||
return plan ?? 'standard';
|
||||
}
|
||||
|
||||
return this.getLicenseType(licenseKey) ?? 'free';
|
||||
}
|
||||
|
||||
private getLicenseType(licenseKey: string): string | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const LicenseModule = require('../../ee/licence/license.service');
|
||||
const licenseService = this.moduleRef.get(LicenseModule.LicenseService, {
|
||||
strict: false,
|
||||
});
|
||||
return licenseService.getLicenseType(licenseKey);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class ExportController {
|
||||
|
||||
await this.pageAccessService.validateCanView(page, user);
|
||||
|
||||
const zipFileStream = await this.exportService.exportPages(
|
||||
const result = await this.exportService.exportPages(
|
||||
dto.pageId,
|
||||
dto.format,
|
||||
dto.includeAttachments,
|
||||
@@ -83,15 +83,29 @@ export class ExportController {
|
||||
},
|
||||
});
|
||||
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
if (result.type === 'file') {
|
||||
const ext = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'untitled') + ext;
|
||||
const contentType = getMimeType(path.extname(fileName));
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
res.headers({
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
|
||||
res.send(zipFileStream);
|
||||
res.send(result.content);
|
||||
} else {
|
||||
const fileName = sanitize(page.title || 'untitled') + '.zip';
|
||||
|
||||
res.headers({
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition':
|
||||
'attachment; filename="' + encodeURIComponent(fileName) + '"',
|
||||
});
|
||||
|
||||
res.send(result.stream);
|
||||
}
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
||||
@@ -28,8 +28,7 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { EditorState } from '@tiptap/pm/state';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import slugify = require('@sindresorhus/slugify');
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const packageJson = require('../../../package.json');
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
@@ -151,6 +150,13 @@ export class ExportService {
|
||||
// set to null to make export of pages with parentId work
|
||||
pages[parentPageIndex].parentPageId = null;
|
||||
|
||||
const isSinglePage = pages.length === 1 && !includeAttachments;
|
||||
|
||||
if (isSinglePage) {
|
||||
const pageContent = await this.exportPage(format, pages[0], true);
|
||||
return { type: 'file' as const, content: pageContent, page: pages[0] };
|
||||
}
|
||||
|
||||
const tree = buildTree(pages as Page[]);
|
||||
|
||||
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
|
||||
@@ -171,7 +177,7 @@ export class ExportService {
|
||||
compression: 'DEFLATE',
|
||||
});
|
||||
|
||||
return zipFile;
|
||||
return { type: 'zip' as const, stream: zipFile, page: pages[0] };
|
||||
}
|
||||
|
||||
async exportSpace(
|
||||
@@ -291,6 +297,7 @@ export class ExportService {
|
||||
prosemirrorJson,
|
||||
slugIdToPath,
|
||||
currentPagePath,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
if (includeAttachments) {
|
||||
|
||||
@@ -62,6 +62,7 @@ export function replaceInternalLinks(
|
||||
prosemirrorJson: any,
|
||||
slugIdToPath: Record<string, string>,
|
||||
currentPagePath: string,
|
||||
baseUrl?: string,
|
||||
) {
|
||||
const doc = jsonToNode(prosemirrorJson);
|
||||
|
||||
@@ -76,6 +77,10 @@ export function replaceInternalLinks(
|
||||
const localPath = slugIdToPath[slugId];
|
||||
|
||||
if (!localPath) {
|
||||
if (baseUrl && mark.attrs.href.startsWith('/')) {
|
||||
//@ts-expect-error
|
||||
mark.attrs.href = `${baseUrl}${mark.attrs.href}`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildAttachmentCandidates,
|
||||
collectMarkdownAndHtmlFiles,
|
||||
encodeFilePath,
|
||||
extractNotionPartialId,
|
||||
readDocmostMetadata,
|
||||
stripNotionID,
|
||||
} from '../utils/import.utils';
|
||||
@@ -160,10 +161,17 @@ 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);
|
||||
|
||||
const space = await this.db
|
||||
.selectFrom('spaces')
|
||||
.select(['slug'])
|
||||
.where('id', '=', fileTask.spaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
const pagesMap = new Map<string, ImportPageNode>();
|
||||
|
||||
for (const absPath of allFiles) {
|
||||
@@ -224,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()
|
||||
@@ -237,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -458,6 +512,7 @@ export class FileImportTaskService {
|
||||
creatorId: fileTask.creatorId,
|
||||
sourcePageId: page.id,
|
||||
workspaceId: fileTask.workspaceId,
|
||||
spaceSlug: space?.slug,
|
||||
});
|
||||
|
||||
const pmState = getProsemirrorContent(
|
||||
|
||||
@@ -190,13 +190,41 @@ export class ImportAttachmentService {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map from resolved archive path → real filename from Confluence
|
||||
// metadata. Confluence Server archives often store files under numeric IDs
|
||||
// (e.g. "attachments/65601/65602") instead of the original filename.
|
||||
// Also register aliases so HTML references using the original filename
|
||||
// (e.g. "attachments/pageId/original.mp3") resolve to the numeric path.
|
||||
const pageDir = path.dirname(pageRelativePath);
|
||||
const attachmentNameByRelPath = new Map<string, string>();
|
||||
for (const attachment of pageAttachments) {
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
attachment.href,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (relPath && attachment.fileName) {
|
||||
attachmentNameByRelPath.set(relPath, attachment.fileName);
|
||||
|
||||
const dir = path.posix.dirname(relPath);
|
||||
const aliasKey = `${dir}/${attachment.fileName}`;
|
||||
if (!attachmentCandidates.has(aliasKey)) {
|
||||
attachmentCandidates.set(aliasKey, attachmentCandidates.get(relPath)!);
|
||||
attachmentNameByRelPath.set(aliasKey, attachment.fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadOnce = (relPath: string) => {
|
||||
const abs = attachmentCandidates.get(relPath)!;
|
||||
const attachmentId = v7();
|
||||
const ext = path.extname(abs);
|
||||
|
||||
const realName = attachmentNameByRelPath.get(relPath);
|
||||
const baseName = realName || path.basename(abs);
|
||||
const ext = path.extname(baseName);
|
||||
|
||||
const fileNameWithExt =
|
||||
sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase();
|
||||
sanitizeFileName(path.basename(baseName, ext)) + ext.toLowerCase();
|
||||
|
||||
const storageFilePath = `${getAttachmentFolderPath(
|
||||
AttachmentType.File,
|
||||
@@ -240,7 +268,6 @@ export class ImportAttachmentService {
|
||||
return fresh;
|
||||
};
|
||||
|
||||
const pageDir = path.dirname(pageRelativePath);
|
||||
const $ = load(html);
|
||||
|
||||
// image
|
||||
@@ -335,6 +362,28 @@ export class ImportAttachmentService {
|
||||
unwrapFromParagraph($, $vid);
|
||||
}
|
||||
|
||||
// audio
|
||||
for (const audEl of $('audio').toArray()) {
|
||||
const $aud = $(audEl);
|
||||
const src = cleanUrlString($aud.attr('src') ?? '')!;
|
||||
if (!src || src.startsWith('http')) continue;
|
||||
|
||||
const relPath = resolveRelativeAttachmentPath(
|
||||
src,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!relPath) continue;
|
||||
|
||||
const { attachmentId, apiFilePath } = processFile(relPath);
|
||||
|
||||
$aud
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
|
||||
unwrapFromParagraph($, $aud);
|
||||
}
|
||||
|
||||
// <div data-type="attachment">
|
||||
for (const el of $('div[data-type="attachment"]').toArray()) {
|
||||
const $oldDiv = $(el);
|
||||
@@ -401,7 +450,18 @@ export class ImportAttachmentService {
|
||||
const { attachmentId, apiFilePath, abs } = processFile(relPath);
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
|
||||
if (ext === '.mp4') {
|
||||
const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
|
||||
|
||||
if (ext === '.pdf') {
|
||||
const $pdf = $('<div>')
|
||||
.attr('data-type', 'pdf')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
.attr('width', '800')
|
||||
.attr('height', '600');
|
||||
$a.replaceWith($pdf);
|
||||
unwrapFromParagraph($, $pdf);
|
||||
} else if (ext === '.mp4') {
|
||||
const $video = $('<video>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId)
|
||||
@@ -409,6 +469,12 @@ export class ImportAttachmentService {
|
||||
.attr('data-align', 'center');
|
||||
$a.replaceWith($video);
|
||||
unwrapFromParagraph($, $video);
|
||||
} else if (audioExtensions.has(ext)) {
|
||||
const $audio = $('<audio>')
|
||||
.attr('src', apiFilePath)
|
||||
.attr('data-attachment-id', attachmentId);
|
||||
$a.replaceWith($audio);
|
||||
unwrapFromParagraph($, $audio);
|
||||
} else {
|
||||
const confAliasName = $a.attr('data-linked-resource-default-alias');
|
||||
let attachmentName = path.basename(abs);
|
||||
@@ -505,18 +571,31 @@ export class ImportAttachmentService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already processed (was referenced in HTML)
|
||||
if (processed.has(href)) {
|
||||
continue;
|
||||
}
|
||||
// Resolve the metadata href to the actual archive path
|
||||
const resolvedHref = resolveRelativeAttachmentPath(
|
||||
href,
|
||||
pageDir,
|
||||
attachmentCandidates,
|
||||
);
|
||||
if (!resolvedHref) continue;
|
||||
|
||||
// Skip if the file doesn't exist
|
||||
if (!attachmentCandidates.has(href)) {
|
||||
// Check if already processed (was referenced in HTML).
|
||||
// Inline elements may have been processed under an alias key (original
|
||||
// filename) rather than the numeric archive path, so also check whether
|
||||
// the underlying absolute file path has already been uploaded.
|
||||
const absPath = attachmentCandidates.get(resolvedHref);
|
||||
const alreadyProcessed =
|
||||
processed.has(resolvedHref) ||
|
||||
(absPath &&
|
||||
Array.from(processed.values()).some(
|
||||
(entry) => entry.abs === absPath,
|
||||
));
|
||||
if (alreadyProcessed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This attachment was in the list but not referenced in HTML - add it
|
||||
const { attachmentId, apiFilePath, abs } = processFile(href);
|
||||
const { attachmentId, apiFilePath, abs } = processFile(resolvedHref);
|
||||
const mime = mimeType || getMimeType(abs);
|
||||
|
||||
// Add as attachment node at the end
|
||||
@@ -555,7 +634,7 @@ export class ImportAttachmentService {
|
||||
// Post-process DOM elements to add file sizes after uploads complete
|
||||
// This avoids blocking file operations during initial DOM processing
|
||||
const elementsNeedingSize = $(
|
||||
'[data-attachment-id]:not([data-attachment-size])',
|
||||
'[data-attachment-id]:not([data-attachment-size]):not([data-size])',
|
||||
);
|
||||
for (const element of elementsNeedingSize.toArray()) {
|
||||
const $el = $(element);
|
||||
@@ -570,7 +649,14 @@ export class ImportAttachmentService {
|
||||
if (processedEntry) {
|
||||
try {
|
||||
const stat = await fs.stat(processedEntry.abs);
|
||||
$el.attr('data-attachment-size', stat.size.toString());
|
||||
const sizeStr = stat.size.toString();
|
||||
const tagName = $el.prop('tagName')?.toLowerCase();
|
||||
// audio and pdf nodes use data-size, attachment nodes use data-attachment-size
|
||||
if (tagName === 'audio' || $el.attr('data-type') === 'pdf') {
|
||||
$el.attr('data-size', sizeStr);
|
||||
} else {
|
||||
$el.attr('data-attachment-size', sizeStr);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`Could not get size for ${processedEntry.abs}:`,
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from 'path';
|
||||
import { v7 } from 'uuid';
|
||||
import { InsertableBacklink } from '@docmost/db/types/entity.types';
|
||||
import { Cheerio, CheerioAPI, load } from 'cheerio';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
|
||||
// Check if text contains Unicode characters (for emojis/icons)
|
||||
function isUnicodeCharacter(text: string): boolean {
|
||||
@@ -22,6 +23,7 @@ export async function formatImportHtml(opts: {
|
||||
workspaceId: string;
|
||||
pageDir?: string;
|
||||
attachmentCandidates?: string[];
|
||||
spaceSlug?: string;
|
||||
}): Promise<{
|
||||
html: string;
|
||||
backlinks: InsertableBacklink[];
|
||||
@@ -61,6 +63,7 @@ export async function formatImportHtml(opts: {
|
||||
creatorId,
|
||||
sourcePageId,
|
||||
workspaceId,
|
||||
opts.spaceSlug,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -316,6 +319,7 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
creatorId: string,
|
||||
sourcePageId: string,
|
||||
workspaceId: string,
|
||||
spaceSlug?: string,
|
||||
): Promise<InsertableBacklink[]> {
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/');
|
||||
const backlinks: InsertableBacklink[] = [];
|
||||
@@ -339,19 +343,37 @@ export async function rewriteInternalLinksToMentionHtml(
|
||||
);
|
||||
const meta = filePathToPageMetaMap.get(resolved);
|
||||
if (!meta) return;
|
||||
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);
|
||||
|
||||
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 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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,15 @@ export function resolveRelativeAttachmentPath(
|
||||
'ImportUtils',
|
||||
);
|
||||
}
|
||||
|
||||
// Confluence Server uses "/download/attachments/..." in HTML but the ZIP
|
||||
// stores files under "attachments/...". Strip the "download/" prefix so
|
||||
// the path can match candidates from the archive.
|
||||
const confluenceStripped = mainRel.replace(
|
||||
/^download\/attachments\//,
|
||||
'attachments/',
|
||||
);
|
||||
|
||||
const fallback = path
|
||||
.normalize(path.join(pageDir, mainRel))
|
||||
.split(path.sep)
|
||||
@@ -49,9 +58,13 @@ export function resolveRelativeAttachmentPath(
|
||||
if (attachmentCandidates.has(mainRel)) {
|
||||
return mainRel;
|
||||
}
|
||||
if (confluenceStripped !== mainRel && attachmentCandidates.has(confluenceStripped)) {
|
||||
return confluenceStripped;
|
||||
}
|
||||
if (attachmentCandidates.has(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -81,7 +94,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 {
|
||||
|
||||
@@ -69,6 +69,7 @@ export enum QueueJob {
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||
PAGE_UPDATE_DIGEST = 'page-update-digest',
|
||||
|
||||
AUDIT_LOG = 'audit-log',
|
||||
AUDIT_CLEANUP = 'audit-cleanup',
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface IPageBacklinkJob {
|
||||
pageId: string;
|
||||
workspaceId: string;
|
||||
mentions: MentionNode[];
|
||||
internalLinkSlugIds?: string[];
|
||||
}
|
||||
|
||||
export interface IAddPageWatchersJob {
|
||||
@@ -60,6 +61,13 @@ export interface IPageMentionNotificationJob {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface IPageUpdateNotificationJob {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
actorIds: string[];
|
||||
}
|
||||
|
||||
export interface IPermissionGrantedNotificationJob {
|
||||
userIds: string[];
|
||||
pageId: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function processBacklinks(
|
||||
backlinkRepo: BacklinkRepo,
|
||||
data: IPageBacklinkJob,
|
||||
): Promise<void> {
|
||||
const { pageId, mentions, workspaceId } = data;
|
||||
const { pageId, mentions, workspaceId, internalLinkSlugIds = [] } = data;
|
||||
|
||||
await executeTx(db, async (trx) => {
|
||||
const existingBacklinks = await trx
|
||||
@@ -20,7 +20,28 @@ export async function processBacklinks(
|
||||
.where('sourcePageId', '=', pageId)
|
||||
.execute();
|
||||
|
||||
if (existingBacklinks.length === 0 && mentions.length === 0) {
|
||||
const mentionTargetPageIds = mentions
|
||||
.filter((mention) => mention.entityId !== pageId)
|
||||
.map((mention) => mention.entityId);
|
||||
|
||||
let resolvedLinkPageIds: string[] = [];
|
||||
if (internalLinkSlugIds.length > 0) {
|
||||
const resolvedPages = await trx
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('slugId', 'in', internalLinkSlugIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
resolvedLinkPageIds = resolvedPages
|
||||
.map((p) => p.id)
|
||||
.filter((id) => id !== pageId);
|
||||
}
|
||||
|
||||
const allTargetPageIds = [
|
||||
...new Set([...mentionTargetPageIds, ...resolvedLinkPageIds]),
|
||||
];
|
||||
|
||||
if (existingBacklinks.length === 0 && allTargetPageIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,16 +49,12 @@ export async function processBacklinks(
|
||||
(backlink) => backlink.targetPageId,
|
||||
);
|
||||
|
||||
const targetPageIds = mentions
|
||||
.filter((mention) => mention.entityId !== pageId)
|
||||
.map((mention) => mention.entityId);
|
||||
|
||||
let validTargetPages = [];
|
||||
if (targetPageIds.length > 0) {
|
||||
if (allTargetPageIds.length > 0) {
|
||||
validTargetPages = await trx
|
||||
.selectFrom('pages')
|
||||
.select('id')
|
||||
.where('id', 'in', targetPageIds)
|
||||
.where('id', 'in', allTargetPageIds)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -71,7 +71,10 @@ export class StaticModule implements OnModuleInit {
|
||||
|
||||
app.get(RENDER_PATH, (req: any, res: any) => {
|
||||
const stream = fs.createReadStream(indexFilePath);
|
||||
res.type('text/html').send(stream);
|
||||
res
|
||||
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
.type('text/html')
|
||||
.send(stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,25 +66,25 @@ export class LocalDriver implements StorageDriver {
|
||||
}
|
||||
|
||||
async readStream(filePath: string): Promise<Readable> {
|
||||
try {
|
||||
return createReadStream(this._fullPath(filePath));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read file: ${(err as Error).message}`);
|
||||
const fullPath = this._fullPath(filePath);
|
||||
if (!(await fs.pathExists(fullPath))) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
return createReadStream(fullPath);
|
||||
}
|
||||
|
||||
async readRangeStream(
|
||||
filePath: string,
|
||||
range: { start: number; end: number },
|
||||
): Promise<Readable> {
|
||||
try {
|
||||
return createReadStream(this._fullPath(filePath), {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read file: ${(err as Error).message}`);
|
||||
const fullPath = this._fullPath(filePath);
|
||||
if (!(await fs.pathExists(fullPath))) {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
return createReadStream(fullPath, {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
});
|
||||
}
|
||||
|
||||
async exists(filePath: string): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
import { EnvironmentModule } from '../environment/environment.module';
|
||||
import { parseRedisUrl } from '../../common/helpers';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
useFactory: (environmentService: EnvironmentService) => {
|
||||
const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
|
||||
|
||||
return {
|
||||
throttlers: [{ name: 'auth', ttl: 60_000, limit: 10 }],
|
||||
errorMessage: 'Too many requests',
|
||||
storage: new ThrottlerStorageRedisService(
|
||||
new Redis({
|
||||
host: redisConfig.host,
|
||||
port: redisConfig.port,
|
||||
password: redisConfig.password,
|
||||
db: redisConfig.db,
|
||||
family: redisConfig.family,
|
||||
keyPrefix: 'throttle:',
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
inject: [EnvironmentService],
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class ThrottleModule {}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentCreateEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentMentionEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -23,19 +23,7 @@ export const CommentResolvedEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
inviteLink: string;
|
||||
@@ -17,19 +17,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
|
||||
Please click the button below to accept this invitation.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={inviteLink} style={button}>
|
||||
Accept Invite
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={inviteLink}>Accept Invite</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -19,19 +19,7 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
interface PageUpdate {
|
||||
title: string;
|
||||
url: string;
|
||||
updatedBy: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
userName: string;
|
||||
pageUpdates: PageUpdate[];
|
||||
totalUpdates: number;
|
||||
}
|
||||
|
||||
export const PageUpdateDigestEmail = ({
|
||||
userName,
|
||||
pageUpdates,
|
||||
totalUpdates,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>
|
||||
Hi {getGreetingName(userName)},
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
There {totalUpdates === 1 ? 'has' : 'have'} been{' '}
|
||||
<strong>
|
||||
{totalUpdates} update{totalUpdates === 1 ? '' : 's'}
|
||||
</strong>{' '}
|
||||
since your last update.
|
||||
</Text>
|
||||
|
||||
{pageUpdates.map((page, i) => (
|
||||
<Section key={i} style={pageCard}>
|
||||
<Text style={pageTitle}>
|
||||
<Link href={page.url} style={link}>
|
||||
{page.title}
|
||||
</Link>
|
||||
</Text>
|
||||
{page.updatedBy.length > 0 && (
|
||||
<Text style={updatedByText}>
|
||||
Edited by {page.updatedBy.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
const pageCard = {
|
||||
borderLeft: '3px solid #e8e5ef',
|
||||
paddingLeft: '12px',
|
||||
marginBottom: '12px',
|
||||
};
|
||||
|
||||
const pageTitle = {
|
||||
...paragraph,
|
||||
margin: '0 0 2px 0',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold' as const,
|
||||
};
|
||||
|
||||
const updatedByText = {
|
||||
...paragraph,
|
||||
margin: '0',
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
};
|
||||
|
||||
export default PageUpdateDigestEmail;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Link, Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { content, link, paragraph } from '../css/styles';
|
||||
import { EmailButton, getGreetingName, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
userName: string;
|
||||
actorName: string;
|
||||
pageTitle: string;
|
||||
pageUrl: string;
|
||||
}
|
||||
|
||||
export const PageUpdateEmail = ({
|
||||
userName,
|
||||
actorName,
|
||||
pageTitle,
|
||||
pageUrl,
|
||||
}: Props) => {
|
||||
return (
|
||||
<MailBody>
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {getGreetingName(userName)},</Text>
|
||||
<Text style={paragraph}>
|
||||
<strong>{actorName}</strong> updated{' '}
|
||||
<Link href={pageUrl} style={link}>
|
||||
<strong>{pageTitle}</strong>
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View page</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageUpdateEmail;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Section, Text, Button } from '@react-email/components';
|
||||
import { Section, Text } from '@react-email/components';
|
||||
import * as React from 'react';
|
||||
import { button, content, paragraph } from '../css/styles';
|
||||
import { MailBody } from '../partials/partials';
|
||||
import { content, paragraph } from '../css/styles';
|
||||
import { EmailButton, MailBody } from '../partials/partials';
|
||||
|
||||
interface Props {
|
||||
actorName: string;
|
||||
@@ -25,19 +25,7 @@ export const PermissionGrantedEmail = ({
|
||||
<strong>{pageTitle}</strong>.
|
||||
</Text>
|
||||
</Section>
|
||||
<Section
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15px',
|
||||
paddingBottom: '15px',
|
||||
}}
|
||||
>
|
||||
<Button href={pageUrl} style={button}>
|
||||
View
|
||||
</Button>
|
||||
</Section>
|
||||
<EmailButton href={pageUrl}>View</EmailButton>
|
||||
</MailBody>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { container, footer, h1, logo, main } from '../css/styles';
|
||||
import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles';
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
@@ -35,6 +35,47 @@ export function MailHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
interface EmailButtonProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function EmailButton({ href, children }: EmailButtonProps) {
|
||||
return (
|
||||
<table
|
||||
role="presentation"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ margin: '0 0 15px 15px' }}
|
||||
>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
backgroundColor: buttonStyle.backgroundColor,
|
||||
borderRadius: buttonStyle.borderRadius,
|
||||
textAlign: 'center' as const,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
style={{
|
||||
color: buttonStyle.color,
|
||||
fontFamily: buttonStyle.fontFamily,
|
||||
fontSize: buttonStyle.fontSize,
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
export function MailFooter() {
|
||||
return (
|
||||
<Section style={footer}>
|
||||
@@ -46,3 +87,7 @@ export function MailFooter() {
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function getGreetingName(name?: string): string {
|
||||
return name?.split(' ')[0] || 'there';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user