diff --git a/package-lock.json b/package-lock.json index 52a07d187a..716aa85bfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2569,6 +2569,14 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -10054,10 +10062,11 @@ }, "packages/superdoc": { "name": "@harbour-enterprises/superdoc", - "version": "0.3.8", + "version": "0.4.4", "license": "AGPL-3.0", "dependencies": { "@harbour-enterprises/super-editor": "0.0.1-alpha.0", + "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "jsdom": "^25.0.1", "naive-ui": "^2.39.0", diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index 1ffd5de2e9..d39c86e6df 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -79,8 +79,12 @@ export class Editor extends EventEmitter { onDocumentLocked: () => null, onFirstRender: () => null, onCollaborationReady: () => null, + onException: () => null, // async (file) => url; handleImageUpload: null, + + // telemetry + telemetry: null, }; constructor(options) { @@ -99,7 +103,7 @@ export class Editor extends EventEmitter { }; let initMode = modes[this.options.mode] ?? modes.default; - + initMode(); } @@ -113,6 +117,7 @@ export class Editor extends EventEmitter { this.on('beforeCreate', this.options.onBeforeCreate); this.emit('beforeCreate', { editor: this }); this.on('contentError', this.options.onContentError); + this.on('exception', this.options.onException); this.#createView(); this.initDefaultStyles(); @@ -438,6 +443,9 @@ export class Editor extends EventEmitter { docx: this.options.content, media: this.options.mediaFiles, debug: true, + telemetry: this.options.telemetry, + fileSource: this.options.fileSource, + documentId: this.options.documentId, }); } } @@ -495,9 +503,9 @@ export class Editor extends EventEmitter { try { const { mode, fragment, isHeadless, content, loadFromSchema } = this.options; - + if (mode === 'docx') { - doc = createDocument(this.converter, this.schema); + doc = createDocument(this.converter, this.schema, this); if (fragment && isHeadless) { doc = yXmlFragmentToProseMirrorRootNode(fragment, this.schema); @@ -838,6 +846,11 @@ export class Editor extends EventEmitter { fonts: this.options.fonts, isHeadless: this.options.isHeadless, }); + + this.telemetry?.trackUsage('document_export', { + documentType: 'docx', + timestamp: new Date().toISOString() + }); return result; } diff --git a/packages/super-editor/src/core/helpers/ErrorWithDetails.js b/packages/super-editor/src/core/helpers/ErrorWithDetails.js new file mode 100644 index 0000000000..7c5d85e299 --- /dev/null +++ b/packages/super-editor/src/core/helpers/ErrorWithDetails.js @@ -0,0 +1,21 @@ +/** + * Custom error class which allows to pass additional details + * @param {string} name - Error name + * @param {string} message - Error message + * @param {object} details - additional details from the calling context + */ + +function ErrorWithDetails(name, message, details) { + this.name = name; + this.message = message || ''; + this.details = details; + + const error = new Error(this.message); + this.stack = error.stack; + error.name = this.name; +} +ErrorWithDetails.prototype = Object.create(Error.prototype); + +export { + ErrorWithDetails, +} diff --git a/packages/super-editor/src/core/helpers/createDocument.js b/packages/super-editor/src/core/helpers/createDocument.js index d6cf5007cb..177022cc86 100644 --- a/packages/super-editor/src/core/helpers/createDocument.js +++ b/packages/super-editor/src/core/helpers/createDocument.js @@ -2,10 +2,11 @@ * Creates the document to pass to EditorState. * @param converter SuperConverter instance. * @param schema Schema. + * @param editor Editor * @returns Document. */ -export function createDocument(converter, schema) { - const documentData = converter.getSchema(); +export function createDocument(converter, schema, editor) { + const documentData = converter.getSchema(editor); if (documentData) { return schema.nodeFromJSON(documentData); diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 08e4e75831..e9a17ff31c 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -95,6 +95,14 @@ class SuperConverter { this.tagsNotInSchema = ['w:body']; this.savedTagsToRestore = []; + // Initialize telemetry + this.telemetry = params?.telemetry || null; + this.documentInternalId = null; + + // Uploaded file + this.fileSource = params?.fileSource || null; + this.documentId = params?.documentId || null; + // Parse the initial XML, if provided if (this.docx.length || this.xml) this.parseFromXml(); } @@ -155,6 +163,15 @@ class SuperConverter { return { fontSizePt, kern, typeface, panose }; } } + + getDocumentInternalId() { + const settings = this.convertedXml['word/settings.xml']; + if (!settings) return ''; + // New versions of Word will have w15:docId + // It's possible to have w14:docId as well but Word(2013 and later) will convert it automatically when document opened + const w15DocId = settings.elements[0].elements.find((el) => el.name === 'w15:docId'); + this.documentInternalId = w15DocId?.attributes['w15:val']; + } getThemeInfo(themeName) { themeName = themeName.toLowerCase(); @@ -174,8 +191,10 @@ class SuperConverter { return { typeface, panose }; } - getSchema() { - const result = createDocumentJson({...this.convertedXml, media: this.media }, this ); + getSchema(editor) { + this.getDocumentInternalId(); + const result = createDocumentJson({...this.convertedXml, media: this.media }, this, editor); + if (result) { this.savedTagsToRestore.push({ ...result.savedTagsToRestore }); this.pageStyles = result.pageStyles; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 743ca5718e..bd85fee99b 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -30,9 +30,11 @@ import { listHandlerEntity } from './listImporter.js'; /** * * @param {ParsedDocx} docx + * @param {SuperConverter} converter instance. + * @param {Editor} editor instance. * @returns {{pmDoc: PmNodeJson, savedTagsToRestore: XmlNode, pageStyles: *}|null} */ -export const createDocumentJson = (docx, converter) => { +export const createDocumentJson = (docx, converter, editor) => { const json = carbonCopy(getInitialJSON(docx)); if (!json) return null; @@ -44,7 +46,7 @@ export const createDocumentJson = (docx, converter) => { const ignoreNodes = ['w:sectPr']; const content = node.elements?.filter((n) => !ignoreNodes.includes(n.name)) ?? []; - const parsedContent = nodeListHandler.handler(content, docx, false); + const parsedContent = nodeListHandler.handler(content, docx, false, converter, editor); const result = { type: 'doc', content: parsedContent, @@ -52,10 +54,24 @@ export const createDocumentJson = (docx, converter) => { attributes: json.elements[0].attributes, }, }; + + // Not empty document + if (result.content.length > 1) { + converter?.telemetry?.trackUsage( + converter?.fileSource, + converter?.documentId, + 'document_import', + { + documentType: 'docx', + internalId: converter?.documentInternalId, + timestamp: new Date().toISOString() + }); + } + return { pmDoc: result, savedTagsToRestore: node, - pageStyles: getDocumentStyles(node, docx, converter), + pageStyles: getDocumentStyles(node, docx, converter, editor), }; } return null; @@ -91,48 +107,198 @@ export const defaultNodeListHandler = () => { */ const createNodeListHandler = (nodeHandlers) => { /** - * @param {XmlNode[]} elements - * @param {ParsedDocx} docx - * @param {boolean} insideTrackChange - * @param {string} filename - * @return {{type: string, content: *, attrs: {attributes}}[]} + * Gets safe element context even if index is out of bounds + * @param {Array} elements Array of elements + * @param {number} index Index to check + * @returns {Object} Safe context object */ - const nodeListHandlerFn = (elements, docx, insideTrackChange, filename) => { + const getSafeElementContext = (elements, index) => { + if (!elements || index < 0 || index >= elements.length) { + return { + elementIndex: index, + error: 'index_out_of_bounds', + arrayLength: elements?.length + }; + } + + const element = elements[index]; + return { + elementIndex: index, + elementName: element?.name, + elementAttributes: element?.attributes, + hasElements: !!element?.elements, + elementCount: element?.elements?.length + }; + }; + + const nodeListHandlerFn = (elements, docx, insideTrackChange, converter, editor, filename) => { if (!elements || !elements.length) return []; + const processedElements = []; - - for (let index = 0; index < elements.length; index++) { - const { nodes, consumed } = nodeHandlers.reduce( - (res, handler) => { - if (res.consumed > 0) return res; + const unhandledNodes = []; + + try { + for (let index = 0; index < elements.length; index++) { + try { const nodesToHandle = elements.slice(index); - if (!nodesToHandle || nodesToHandle.length === 0) return res; - const result = handler.handler( - nodesToHandle, - docx, - { handler: nodeListHandlerFn, handlerEntities: nodeHandlers }, - insideTrackChange, - filename, + if (!nodesToHandle || nodesToHandle.length === 0) { + converter?.telemetry?.trackParsing( + converter?.fileSource, + converter?.documentId, + 'node', + 'empty_slice', + `/word/${filename || 'document.xml'}`, + { + index, + internalId: converter?.documentInternalId, + totalElements: elements.length, + }); + continue; + } + + const { nodes, consumed, unhandled } = nodeHandlers.reduce( + (res, handler) => { + if (res.consumed > 0) return res; + + return handler.handler( + nodesToHandle, + docx, + { handler: nodeListHandlerFn, handlerEntities: nodeHandlers }, + insideTrackChange, + converter, + editor, + filename + ); + }, + { nodes: [], consumed: 0 } ); - return result; - }, - { nodes: [], consumed: 0 }, - ); - index += consumed - 1; - if (consumed === 0) { - console.warn("We have a node that we can't handle!", elements[index]); - } - for (let node of nodes) { - if (node?.type) { - const ignore = ['runProperties']; - // Ignore empty text nodes - if (node.type === 'text' && Array.isArray(node.content) && !node.content.length) continue; - if (!ignore.includes(node.type)) processedElements.push(node); + // Track unhandled nodes with safe context + if (unhandled) { + const context = getSafeElementContext(elements, index); + if (!context.elementName) continue; + + unhandledNodes.push({ + name: context.elementName, + attributes: context.elementAttributes + }); + + converter?.telemetry?.trackParsing( + converter?.fileSource, + converter?.documentId, + 'node', + 'unhandled', + `/word/${filename || 'document.xml'}`, + { + context, + internalId: converter?.documentInternalId, + }); + + continue; + } + + // Bounds check before incrementing index + if (consumed > 0) { + index += consumed - 1; + if (index < 0) { + converter?.telemetry?.trackParsing( + converter?.fileSource, + converter?.documentId, + 'node', + 'invalid_index', + `/word/${filename || 'document.xml'}`, + { + originalIndex: index - (consumed - 1), + consumed, + resultingIndex: index, + internalId: converter?.documentInternalId, + } + ); + index = 0; // Reset to safe value + } + } + + // Process valid nodes + for (let node of (nodes || [])) { + if (node?.type) { + const ignore = ['runProperties']; + + if (node.type === 'text' && Array.isArray(node.content) && !node.content.length) { + + converter?.telemetry?.trackParsing( + converter?.fileSource, + converter?.documentId, + 'node', + 'empty_text', + `/word/${filename || 'document.xml'}`, + { + context: node.attrs, + internalId: converter?.documentInternalId, + }); + continue; + } + + if (!ignore.includes(node.type)) { + processedElements.push(node); + } + } + } + } catch (error) { + editor?.emit('exception', { error }); + + const context = getSafeElementContext(elements, index); + if (error.details) { + context.elementAttributes = error.details; + } + + // Track individual element processing errors with safe context + converter?.telemetry?.trackParsing( + converter?.fileSource, + converter?.documentId, + 'element', + 'processing_error', + `/word/${filename || 'document.xml'}`, + { + error: { + message: error.message, + name: error.name, + stack: error.stack, + }, + internalId: converter?.documentInternalId, + context + } + ); } } + + return processedElements; + } catch (error) { + editor?.emit('exception', { error }); + + // Track catastrophic errors in the node list handler + converter?.telemetry?.trackParsing( + converter?.fileSource, + converter?.documentId, + 'handler', + 'nodeListHandler', + `/word/${filename || 'document.xml'}`, + { + status: 'error', + error: { + message: error.message, + name: error.name, + stack: error.stack + }, + internalId: converter?.documentInternalId, + context: { + totalElements: elements?.length || 0, + processedCount: processedElements.length, + unhandledCount: unhandledNodes.length, + } + } + ); + throw error; } - return processedElements; }; return nodeListHandlerFn; @@ -143,7 +309,7 @@ const createNodeListHandler = (nodeHandlers) => { * @param {XmlNode} node * @returns {Object} The document styles object */ -function getDocumentStyles(node, docx, converter) { +function getDocumentStyles(node, docx, converter, editor) { const sectPr = node.elements?.find((n) => n.name === 'w:sectPr'); const styles = {}; @@ -181,17 +347,17 @@ function getDocumentStyles(node, docx, converter) { }; break; case 'w:headerReference': - getHeaderFooter(el, 'header', docx, converter); + getHeaderFooter(el, 'header', docx, converter, editor); break; case 'w:footerReference': - getHeaderFooter(el, 'footer', docx, converter); + getHeaderFooter(el, 'footer', docx, converter, editor); break; } }); return styles; } -function getHeaderFooter(el, elementType, docx, converter) { +function getHeaderFooter(el, elementType, docx, converter, editor) { const rels = docx['word/_rels/document.xml.rels']; const relationships = rels.elements.find((el) => el.name === 'Relationships'); const { elements } = relationships; @@ -208,7 +374,7 @@ function getHeaderFooter(el, elementType, docx, converter) { const currentFileName = target; const nodeListHandler = defaultNodeListHandler(); - const schema = nodeListHandler.handler(referenceFile.elements[0].elements, docx, false, currentFileName); + const schema = nodeListHandler.handler(referenceFile.elements[0].elements, docx, false, converter, editor, currentFileName); let storage, storageIds; @@ -222,4 +388,4 @@ function getHeaderFooter(el, elementType, docx, converter) { storage[rId] = { type: 'doc', content: [...schema] }; storageIds[sectionType] = rId; -}; \ No newline at end of file +}; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js index 291483f673..77499fb68e 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/imageImporter.js @@ -3,7 +3,7 @@ import { emuToPixels } from '../../helpers.js'; /** * @type {import("docxImporter").NodeHandler} */ -export const handleDrawingNode = (nodes, docx, nodeListHandler, insideTrackChange, filename) => { +export const handleDrawingNode = (nodes, docx, nodeListHandler, insideTrackChange, converter, editor, filename) => { if (nodes.length === 0 || nodes[0].name !== 'w:drawing') { return { nodes: [], consumed: 0 }; } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js index 252266d8b7..8709225ee6 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/listImporter.js @@ -2,6 +2,7 @@ import { carbonCopy } from '../../../utilities/carbonCopy.js'; import { hasTextNode, parseProperties } from './importerHelpers.js'; import { preProcessNodesForFldChar } from './paragraphNodeImporter.js'; import { mergeTextNodes } from './mergeTextNodes.js'; +import { ErrorWithDetails } from '../../../helpers/ErrorWithDetails.js'; /** * @type {import("docxImporter").NodeHandler} @@ -372,7 +373,7 @@ const getListLevelDefinitionTag = (numId, level, pStyleId, docx) => { } const start = currentLevel?.elements?.find((style) => style.name === 'w:start')?.attributes['w:val']; - const numFmt = currentLevel?.elements?.find((style) => style.name === 'w:numFmt').attributes['w:val']; + const numFmt = currentLevel?.elements?.find((style) => style.name === 'w:numFmt')?.attributes['w:val']; const lvlText = currentLevel?.elements?.find((style) => style.name === 'w:lvlText').attributes['w:val']; const lvlJc = currentLevel?.elements?.find((style) => style.name === 'w:lvlJc').attributes['w:val']; const pPr = currentLevel?.elements?.find((style) => style.name === 'w:pPr'); @@ -425,10 +426,13 @@ export function getNodeNumberingDefinition(attributes, level, docx) { // Get style for this list level let listType; - if (unorderedListTypes.includes(listTypeDef.toLowerCase())) listType = 'bulletList'; + if (unorderedListTypes.includes(listTypeDef?.toLowerCase())) listType = 'bulletList'; else if (orderedListTypes.includes(listTypeDef)) listType = 'orderedList'; else { - throw new Error(`Unknown list type found during import: ${listTypeDef}`); + throw new ErrorWithDetails('ListParsingError', `Unknown list type found during import: ${listTypeDef}`, { + listOrderingType: listTypeDef, ilvl, numId, listrPrs, listpPrs, start, lvlText, lvlJc + }); + // throw new Error(`Unknown list type found during import: ${listTypeDef}`); } return { listType, listOrderingType: listTypeDef, ilvl, numId, listrPrs, listpPrs, start, lvlText, lvlJc }; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js index 9897d4f00f..72d5c890ee 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/paragraphNodeImporter.js @@ -11,7 +11,7 @@ import { mergeTextNodes } from './mergeTextNodes.js'; * * @type {import("docxImporter").NodeHandler} */ -export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackChange, filename) => { +export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackChange, converter, editor, filename) => { if (nodes.length === 0 || nodes[0].name !== 'w:p') { return { nodes: [], consumed: 0 }; } @@ -38,7 +38,7 @@ export const handleParagraphNode = (nodes, docx, nodeListHandler, insideTrackCha return { nodes: [], consumed: 0 }; } - const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange, filename); + const result = handleStandardNode([node], docx, nodeListHandler, insideTrackChange, converter, editor, filename); if (result.nodes.length === 1) { schemaNode = result.nodes[0]; } diff --git a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js index 76409a748b..0866f78d15 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/runNodeImporter.js @@ -4,11 +4,11 @@ import { createImportMarks } from './markImporter.js'; /** * @type {import("docxImporter").NodeHandler} */ -const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false, filename) => { +const handleRunNode = (nodes, docx, nodeListHandler, insideTrackChange = false, converter, editor, filename) => { if (nodes.length === 0 || nodes[0].name !== 'w:r') { return { nodes: [], consumed: 0 }; } - + const node = nodes[0]; let processedRun = nodeListHandler.handler(node.elements, docx, insideTrackChange, filename)?.filter((n) => n) || []; const hasRunProperties = node.elements.some((el) => el.name === 'w:rPr'); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js index 8c7ea5c5ae..720eb56773 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/standardNodeImporter.js @@ -3,7 +3,7 @@ import { getElementName, parseProperties } from './importerHelpers.js'; /** * @type {import("docxImporter").NodeHandler} */ -export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChange = false, filename) => { +export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChange = false, converter, editor, filename) => { if (!nodes || nodes.length === 0) { return { nodes: [], consumed: 0 }; } @@ -11,16 +11,30 @@ export const handleStandardNode = (nodes, docx, nodeListHandler, insideTrackChan // Parse properties const { name, type } = node; const { attributes, elements, marks = [] } = parseProperties(node, docx); + + if (!getElementName(node)) { + return { + nodes: [{ + type: name, + content: elements, + attrs: { ...attributes }, + marks, + }], + consumed: 0, + unhandled: true, + }; + } // Iterate through the children and build the schemaNode content + // Skip run properties since they are formatting only elements const content = []; - if (elements && elements.length) { + if (elements && elements.length && name !== 'w:rPr') { const updatedElements = elements.map((el) => { if (!el.marks) el.marks = []; el.marks.push(...marks); return el; }); - content.push(...nodeListHandler.handler(updatedElements, docx, insideTrackChange, filename)); + content.push(...nodeListHandler.handler(updatedElements, docx, insideTrackChange, converter, editor, filename)); } const resultNode = { diff --git a/packages/super-editor/src/dev/components/DeveloperPlayground.vue b/packages/super-editor/src/dev/components/DeveloperPlayground.vue index 95b058d6a1..a299b52589 100644 --- a/packages/super-editor/src/dev/components/DeveloperPlayground.vue +++ b/packages/super-editor/src/dev/components/DeveloperPlayground.vue @@ -8,16 +8,17 @@ import { SuperEditor } from '@/index.js'; import { getFileObject } from '@harbour-enterprises/common/helpers/get-file-object'; import { DOCX } from '@harbour-enterprises/common'; import { SuperToolbar } from '@components/toolbar/super-toolbar'; -import { fieldAnnotationHelpers } from '@extensions/index.js'; import { PaginationPluginKey } from '@extensions/pagination/pagination-helpers.js'; import BasicUpload from './BasicUpload.vue'; import BlankDOCX from '@harbour-enterprises/common/data/blank.docx?url'; +import { Telemetry } from '@harbour-enterprises/common/Telemetry.js'; // Import the component the same you would in your app let activeEditor; const currentFile = ref(null); const pageStyles = ref(null); const isDebuggingPagination = ref(false); +const telemetry = ref(null); const handleNewFile = async (file) => { currentFile.value = null; @@ -65,6 +66,7 @@ const editorOptions = computed(() => { suppressSkeletonLoader: true, users: [], // For comment @-mentions, only users that have access to the document pagination: true, + telemetry: telemetry.value, } }); @@ -106,6 +108,11 @@ const debugPageStyle = computed(() => { onMounted(async () => { // set document to blank currentFile.value = await getFileObject(BlankDOCX, 'blank_document.docx', DOCX); + + telemetry.value = new Telemetry({ + enabled: true, + superdocId: 'dev-playground', + }); }); diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 3a60b5b2e2..5d2545e30f 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@harbour-enterprises/super-editor": "0.0.1-alpha.0", + "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "jsdom": "^25.0.1", "naive-ui": "^2.39.0", diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 36c3cf7b6a..85de5fa9b7 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -272,6 +272,10 @@ const onEditorContentError = ({ error, editor }) => { proxy.$superdoc.emit('content-error', { error, editor }); }; +const onEditorException = ({ error, editor }) => { + proxy.$superdoc.emit('exception', { error, editor }); +}; + const updateToolbarState = () => { proxy.$superdoc.toolbar.updateToolbarState(); }; @@ -296,6 +300,7 @@ const editorOptions = (doc) => { onSelectionUpdate: onEditorSelectionChange, onCollaborationReady: onEditorCollaborationReady, onContentError: onEditorContentError, + onException: onEditorException, // onCommentsLoaded, // onCommentClicked, // onCommentsUpdate: onEditorCommentsUpdate, @@ -303,6 +308,7 @@ const editorOptions = (doc) => { collaborationProvider: doc.provider || null, isNewFile: doc.isNewFile || false, handleImageUpload: proxy.$superdoc.config.handleImageUpload, + telemetry: proxy.$superdoc.telemetry, }; return options; diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 9334cf7a64..3754fdc22e 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -12,6 +12,7 @@ import { SuperToolbar } from '@harbour-enterprises/super-editor'; import { createAwarenessHandler, createProvider } from './collaboration/collaboration'; import { createSuperdocVueApp } from './create-app'; import { shuffleArray } from '@harbour-enterprises/common/collaboration/awareness.js'; +import { Telemetry } from '@harbour-enterprises/common/Telemetry.js'; /** * @typedef {Object} SuperdocUser The current user of this superdoc @@ -56,6 +57,9 @@ export class SuperDoc extends EventEmitter { isDev: false, + // telemetry config + telemetry: null, + // Events onEditorBeforeCreate: () => null, onEditorCreate: () => null, @@ -68,6 +72,7 @@ export class SuperDoc extends EventEmitter { onPdfDocumentReady: () => null, onSidebarToggle: () => null, onCollaborationReady: () => null, + onException: () => null, // Image upload handler // async (file) => url; @@ -95,6 +100,8 @@ export class SuperDoc extends EventEmitter { // Initialize collaboration if configured await this.#initCollaboration(this.config.modules); + + this.#initTelemetry(); this.#initVueApp(); this.#initListeners(); @@ -158,6 +165,7 @@ export class SuperDoc extends EventEmitter { this.on('sidebar-toggle', this.config.onSidebarToggle); this.on('collaboration-ready', this.config.onCollaborationReady); this.on('content-error', this.onContentError); + this.on('exception', this.config.onException); } /* ** @@ -207,6 +215,19 @@ export class SuperDoc extends EventEmitter { return processedDocuments; } + /** + * Initialize telemetry service. + */ + #initTelemetry() { + if (this.config.telemetry?.enabled) { + this.telemetry = new Telemetry({ + enabled: this.config.telemetry.enabled ?? true, + licenceKey: this.config.telemetry.licenceKey, + superdocId: this.superdocId, + }); + } + } + onContentError({ error, editor }) { const { documentId } = editor.options; const doc = this.superdocStore.documents.find((d) => d.id === documentId); @@ -424,6 +445,9 @@ export class SuperDoc extends EventEmitter { }); this.superdocStore.reset(); + + // Clean up telemetry when editor is destroyed + this.telemetry?.destroy(); this.app.unmount(); this.removeAllListeners(); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 655ac49516..89da943674 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -64,6 +64,9 @@ const init = async () => { // url: 'ws://localhost:3050/docs/superdoc-id', // } }, + telemetry: { + enabled: true, + }, onEditorCreate, onContentError, // handleImageUpload: async (file) => url, diff --git a/shared/common/Telemetry.js b/shared/common/Telemetry.js new file mode 100644 index 0000000000..bb0d17402c --- /dev/null +++ b/shared/common/Telemetry.js @@ -0,0 +1,294 @@ +/** + * @typedef {Object} BaseEvent + * @property {string} id - Unique event identifier + * @property {string} timestamp - ISO timestamp of the event + * @property {string} sessionId - Current session identifier + * @property {string} superdocId - SuperDoc ID + * @property {Object} document - Document information + * @property {string} [document.id] - Reference ID + * @property {string} [document.type] - Document type + * @property {string} [document.internalId] - Internal document ID + * @property {string} [document.hash] - Document CRC32 hash + * @property {string} [document.lastModified] - Last modified timestamp + */ + +/** + * @typedef {Object} UsageEvent + * @param {File} fileSource - File object + * @param {string} documentId - document id + * @property {string} name - Name of the usage event + * @property {Object.} properties - Event properties + */ + +/** + * @typedef {Object} ParsingEvent + * @param {File} fileSource - File object + * @param {string} documentId - document id + * @property {'mark'|'element'} category - Category of the parsed item + * @property {string} name - Name of the parsed item + * @property {string} path - Document path where item was found + * @property {Object.} [metadata] - Additional context + */ + +/** @typedef {(UsageEvent & BaseEvent) | (ParsingEvent & BaseEvent)} TelemetryEvent */ + +/** + * @typedef {Object} TelemetryConfig + * @property {string} [licenceKey] - Licence key for telemetry service + * @property {boolean} [enabled=true] - Whether telemetry is enabled + * @property {string} endpoint - service endpoint + * @property {string} superdocId - SuperDoc id + */ + +import crc32 from 'buffer-crc32'; +import { randomBytes } from 'crypto'; + +class Telemetry { + /** @type {boolean} */ + enabled; + + /** @type {string} */ + superdocId; + + /** @type {string} */ + licenseKey; + + /** @type {string} */ + endpoint; + + /** @type {string} */ + sessionId; + + /** @type {TelemetryEvent[]} */ + events = []; + + /** @type {number|undefined} */ + flushInterval; + + /** @type {number} */ + static BATCH_SIZE = 50; + + /** @type {number} */ + static FLUSH_INTERVAL = 10000; // 30 seconds + + /** @type {string} */ + static COMMUNITY_LICENSE_KEY = 'community-and-eval-agplv3'; + + /** @type {string} */ + static DEFAULT_ENDPOINT = 'https://ingest.superdoc.dev/v1/collect'; + + /** + * Initialize telemetry service + * @param {TelemetryConfig} config + */ + constructor(config = {}) { + this.enabled = config.enabled ?? true; + + this.licenseKey = config.licenceKey ?? Telemetry.COMMUNITY_LICENSE_KEY; + this.endpoint = config.endpoint ?? Telemetry.DEFAULT_ENDPOINT; + this.superdocId = config.superdocId; + + this.sessionId = this.generateId(); + + if (this.enabled) { + this.startPeriodicFlush(); + } + } + + /** + * Create source payload for request + */ + getSourceData() { + return { + userAgent: window.navigator.userAgent, + url: window.location.href, + host: window.location.host, + referrer: document.referrer, + screen: { + width: window.screen.width, + height: window.screen.height, + }, + }; + } + + /** + * Track feature usage + * @param {File} fileSource - File object + * @param {string} documentId - document id + * @param {string} name - Name of the feature/event + * @param {Object.} [properties] - Additional properties + */ + async trackUsage(fileSource, documentId, name, properties = {}) { + if (!this.enabled) return; + + const processedDoc = await this.processDocument(fileSource, { + id: documentId, + internalId: properties.internalId, + }); + + /** @type {UsageEvent & BaseEvent} */ + const event = { + id: this.generateId(), + type: 'usage', + timestamp: new Date().toISOString(), + sessionId: this.sessionId, + superdocId: this.superdocId, + source: this.getSourceData(), + document: processedDoc, + name, + properties, + }; + + this.queueEvent(event); + } + + /** + * Track parsing events + * @param {File} fileSource - File object + * @param {string} documentId - document id + * @param {'mark'|'element'} category - Category of parsed item + * @param {string} name - Name of the item + * @param {string} path - Document path where item was found + * @param {Object.} [metadata] - Additional context + */ + async trackParsing(fileSource, documentId, category, name, path, metadata) { + if (!this.enabled) return; + + const processedDoc = await this.processDocument(fileSource, { + id: documentId, + internalId: metadata.internalId, + }); + + /** @type {ParsingEvent & BaseEvent} */ + const event = { + id: this.generateId(), + type: 'parsing', + timestamp: new Date().toISOString(), + sessionId: this.sessionId, + superdocId: this.superdocId, + source: this.getSourceData(), + category, + document: processedDoc, + name, + path, + ...(metadata && { metadata }), + }; + + this.queueEvent(event); + } + + /** + * Process document metadata + * @param {File} file - Document file + * @param {Object} options - Additional metadata options + * @returns {Promise} Document metadata + */ + async processDocument(file, options = {}) { + let hash = ''; + try { + hash = await this.generateCrc32Hash(file); + } catch (error) { + console.error('Failed to retrieve file hash:', error); + } + + return { + id: options.id, + type: options.type || file.type, + internalId: options.internalId, + hash, + lastModified: file.lastModified ? new Date(file.lastModified).toISOString() : null, + }; + } + + /** + * Generate CRC32 hash for a file + * @param {File} file - File to hash + * @returns {Promise} CRC32 hash + * @private + */ + async generateCrc32Hash(file) { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const hashBuffer = crc32(buffer); + const hashArray = Array.from(hashBuffer); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } + + /** + * Queue event for sending + * @param {TelemetryEvent} event + * @private + */ + queueEvent(event) { + this.events.push(event); + + if (this.events.length >= Telemetry.BATCH_SIZE) { + this.flush(); + } + } + + /** + * Flush queued events to server + * @returns {Promise} + */ + async flush() { + if (!this.enabled || !this.events.length) return; + + const eventsToSend = [...this.events]; + this.events = []; + + try { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-License-Key': this.licenseKey, + }, + body: JSON.stringify(eventsToSend), + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + } catch (error) { + console.error('Failed to upload telemetry:', error); + // Add events back to queue + this.events = [...eventsToSend, ...this.events]; + } + } + + /** + * Start periodic flush interval + * @private + */ + startPeriodicFlush() { + this.flushInterval = setInterval(() => { + if (this.events.length > 0) { + this.flush(); + } + }, Telemetry.FLUSH_INTERVAL); + } + + /** + * Generate unique identifier + * @returns {string} + * @private + */ + generateId() { + const randomValue = randomBytes(4).toString('hex'); + return `${Date.now()}-${randomValue}`; + } + + /** + * Clean up telemetry service + * @returns {Promise} + */ + destroy() { + if (this.flushInterval) { + clearInterval(this.flushInterval); + } + return this.flush(); + } +} + +export { Telemetry }; diff --git a/shared/common/index.js b/shared/common/index.js index b2bb4cdf3f..df1af6617c 100644 --- a/shared/common/index.js +++ b/shared/common/index.js @@ -5,3 +5,4 @@ export * from './helpers/get-file-object.js'; export * from './helpers/compare-superdoc-versions.js'; export { default as vClickOutside } from './helpers/v-click-outside.js'; export { default as BasicUpload } from './components/BasicUpload.vue'; +export { Telemetry } from './Telemetry.js';