mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 14:43:06 +08:00
feat(editor): audio and PDF nodes (#2064)
* use local resizable * feat: aduio * support audio imports * feat: use confluence real file names * cleanup * error handling * hide notice * add audio * fix pulse * Fix import and export * unify pulse * hide in readonly mode * keywords * keyword * translations * better sort * feat: PDF embed * cleanup * remove audio menu * open active * hide focus on readonly mode * increase iframe default dimension
This commit is contained in:
@@ -24,6 +24,8 @@ import {
|
||||
CustomTable,
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
TiptapPdf,
|
||||
TrailingNode,
|
||||
Attachment,
|
||||
Drawio,
|
||||
@@ -86,6 +88,8 @@ export const tiptapExtensions = [
|
||||
Youtube,
|
||||
TiptapImage,
|
||||
TiptapVideo,
|
||||
TiptapAudio,
|
||||
TiptapPdf,
|
||||
Callout,
|
||||
Attachment,
|
||||
CustomCodeBlock,
|
||||
|
||||
@@ -102,6 +102,8 @@ export function isAttachmentNode(nodeType: string) {
|
||||
'attachment',
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
'pdf',
|
||||
'excalidraw',
|
||||
'drawio',
|
||||
];
|
||||
|
||||
@@ -15,4 +15,9 @@ export const inlineFileExtensions = [
|
||||
'.pdf',
|
||||
'.mp4',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.wav',
|
||||
'.ogg',
|
||||
'.m4a',
|
||||
'.webm',
|
||||
];
|
||||
|
||||
@@ -457,6 +457,10 @@ export class AttachmentController {
|
||||
const rangeHeader = req.headers.range;
|
||||
|
||||
res.header('Accept-Ranges', 'bytes');
|
||||
res.header(
|
||||
'Content-Security-Policy',
|
||||
"base-uri 'none'; object-src 'self'; default-src 'self';",
|
||||
);
|
||||
|
||||
if (!inlineFileExtensions.includes(attachment.fileExt)) {
|
||||
res.header(
|
||||
|
||||
@@ -190,13 +190,32 @@ 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.
|
||||
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 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 +259,6 @@ export class ImportAttachmentService {
|
||||
return fresh;
|
||||
};
|
||||
|
||||
const pageDir = path.dirname(pageRelativePath);
|
||||
const $ = load(html);
|
||||
|
||||
// image
|
||||
@@ -335,6 +353,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 +441,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 +460,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);
|
||||
@@ -555,7 +612,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 +627,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}:`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user