diff --git a/packages/super-editor/src/core/Editor.js b/packages/super-editor/src/core/Editor.js index d972f0d462..a47606acb3 100644 --- a/packages/super-editor/src/core/Editor.js +++ b/packages/super-editor/src/core/Editor.js @@ -913,20 +913,37 @@ export class Editor extends EventEmitter { * @returns {string} Document version */ static getDocumentVersion(doc) { - const version = SuperConverter.getStoredSuperdocVersion(doc); - return version; + return SuperConverter.getStoredSuperdocVersion(doc); } /** - * Update the document version + * Set the document version * @static * @param {Object} doc - Document object * @param {string} version - New version - * @returns {Object} + * @returns {string} The set version + */ + static setDocumentVersion(doc, version) { + return SuperConverter.setStoredSuperdocVersion(doc, version); + } + + /** + * Get the document GUID + * @static + * @param {Object} doc - Document object + * @returns {string|null} Document GUID + */ + static getDocumentGuid(doc) { + return SuperConverter.extractDocumentGuid(doc); + } + + // Deprecated + /** + * @deprecated use setDocumentVersion instead */ static updateDocumentVersion(doc, version) { - const updatedContent = SuperConverter.updateDocumentVersion(doc, version); - return updatedContent; + console.warn('updateDocumentVersion is deprecated, use setDocumentVersion instead'); + return Editor.setDocumentVersion(doc, version); } /** @@ -1369,12 +1386,63 @@ export class Editor extends EventEmitter { return; } + // Track document modifications and promote to GUID if needed + if (transaction.docChanged && this.converter) { + if (!this.converter.documentGuid) { + this.converter.promoteToGuid(); + console.debug('Document modified - assigned GUID:', this.converter.documentGuid); + } + this.converter.documentModified = true; + } + this.emit('update', { editor: this, transaction, }); } + /** + * Get document identifier for telemetry (async - may generate hash) + * @returns {Promise} GUID for modified docs, hash for unmodified + */ + async getDocumentIdentifier() { + return (await this.converter?.getDocumentIdentifier()) || null; + } + + /** + * Get permanent document GUID (sync - only for modified documents) + * @returns {string|null} GUID or null if document hasn't been modified + */ + getDocumentGuid() { + return this.converter?.documentGuid || null; + } + + /** + * Check if document has been modified + * @returns {boolean} + */ + isDocumentModified() { + return this.converter?.documentModified || false; + } + + /** + * Get telemetry data (async because of lazy hash generation) + */ + async getTelemetryData() { + return { + documentId: await this.getDocumentIdentifier(), + isModified: this.isDocumentModified(), + isPermanentId: !!this.converter?.documentGuid, + version: this.converter?.getSuperdocVersion(), + }; + } + + // Deprecated for backward compatibility + getDocumentId() { + console.warn('getDocumentId is deprecated, use getDocumentGuid instead'); + return this.getDocumentGuid(); + } + /** * Get attrs of the currently selected node or mark. * @param {String} nameOrType @@ -1409,6 +1477,22 @@ export class Editor extends EventEmitter { return this.state.doc.toJSON(); } + /** + * Get document metadata including GUID, modification status, and version + * @returns {{ + * documentGuid: string | null, + * isModified: boolean, + * version: string | null + * }} Document metadata + */ + getMetadata() { + return { + documentGuid: this.converter?.documentGuid || null, + isModified: this.isDocumentModified(), + version: this.converter?.getSuperdocVersion() || null, + }; + } + /** * Get the editor content as HTML * @param {Object} options - Options for the HTML serializer @@ -1427,6 +1511,14 @@ export class Editor extends EventEmitter { return html; } + /** + * Get the document version from the converter + * @returns {string|null} The SuperDoc version stored in the document + */ + getDocumentVersion() { + return this.converter?.getSuperdocVersion() || null; + } + /** * Create a child editor linked to this editor. * This is useful for creating header/footer editors that are linked to the main editor. @@ -1557,6 +1649,7 @@ export class Editor extends EventEmitter { const json = this.#prepareDocumentForExport(comments); // Export the document to DOCX + // GUID will be handled automatically in converter.exportToDocx if document was modified const documentXml = await this.converter.exportToDocx( json, this.schema, @@ -1575,7 +1668,10 @@ export class Editor extends EventEmitter { const customXml = this.converter.schemaToXml(this.converter.convertedXml['docProps/custom.xml'].elements[0]); const styles = this.converter.schemaToXml(this.converter.convertedXml['word/styles.xml'].elements[0]); - const customSettings = this.converter.schemaToXml(this.converter.convertedXml['word/settings.xml'].elements[0]); + const hasCustomSettings = !!this.converter.convertedXml['word/settings.xml']?.elements?.length; + const customSettings = hasCustomSettings + ? this.converter.schemaToXml(this.converter.convertedXml['word/settings.xml']?.elements?.[0]) + : null; const rels = this.converter.schemaToXml(this.converter.convertedXml['word/_rels/document.xml.rels'].elements[0]); const media = this.converter.addedMedia; @@ -1593,7 +1689,6 @@ export class Editor extends EventEmitter { ...this.options.customUpdatedFiles, 'word/document.xml': String(documentXml), 'docProps/custom.xml': String(customXml), - 'word/settings.xml': String(customSettings), 'word/_rels/document.xml.rels': String(rels), 'word/numbering.xml': String(numbering), @@ -1602,6 +1697,10 @@ export class Editor extends EventEmitter { ...updatedHeadersFooters, }; + if (hasCustomSettings) { + updatedDocs['word/settings.xml'] = String(customSettings); + } + if (comments.length) { const commentsXml = this.converter.schemaToXml(this.converter.convertedXml['word/comments.xml'].elements[0]); const commentsExtendedXml = this.converter.schemaToXml( diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 070eb1c0ae..0ffc35010e 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -1,11 +1,11 @@ import xmljs from 'xml-js'; import { v4 as uuidv4 } from 'uuid'; - +import crc32 from 'buffer-crc32'; import { DocxExporter, exportSchemaToJson } from './exporter'; import { createDocumentJson, addDefaultStylesIfMissing } from './v2/importer/docxImporter.js'; import { deobfuscateFont, getArrayBufferFromUrl } from './helpers.js'; import { baseNumbering } from './v2/exporter/helpers/base-list.definitions.js'; -import { DEFAULT_CUSTOM_XML, DEFAULT_DOCX_DEFS, SETTINGS_CUSTOM_XML } from './exporter-docx-defs.js'; +import { DEFAULT_CUSTOM_XML, DEFAULT_DOCX_DEFS } from './exporter-docx-defs.js'; import { getCommentDefinition, prepareCommentParaIds, @@ -221,6 +221,11 @@ class SuperConverter { this.fileSource = params?.fileSource || null; this.documentId = params?.documentId || null; + // Document identification + this.documentGuid = null; // Permanent GUID for modified documents + this.documentHash = null; // Temporary hash for unmodified documents + this.documentModified = false; // Track if document has been edited + // Parse the initial XML, if provided if (this.docx.length || this.xml) this.parseFromXml(); } @@ -249,6 +254,9 @@ class SuperConverter { if (!this.initialJSON) this.initialJSON = this.parseXmlToJson(this.xml); this.declaration = this.initialJSON?.declaration; + + // Only resolve existing GUIDs synchronously (no hash generation yet) + this.resolveDocumentGuid(); } parseXmlToJson(xml) { @@ -257,53 +265,246 @@ class SuperConverter { return JSON.parse(xmljs.xml2json(newXml, null, 2)); } - static getStoredSuperdocVersion(docx) { + /** + * Generic method to get a stored custom property from docx + * @static + * @param {Array} docx - Array of docx file objects + * @param {string} propertyName - Name of the property to retrieve + * @returns {string|null} The property value or null if not found + */ + static getStoredCustomProperty(docx, propertyName) { try { const customXml = docx.find((doc) => doc.name === 'docProps/custom.xml'); - if (!customXml) return; + if (!customXml) return null; const converter = new SuperConverter(); const content = customXml.content; const contentJson = converter.parseXmlToJson(content); const properties = contentJson.elements.find((el) => el.name === 'Properties'); - if (!properties.elements) return; + if (!properties.elements) return null; - const superdocVersion = properties.elements.find( - (el) => el.name === 'property' && el.attributes.name === 'SuperdocVersion', - ); - if (!superdocVersion) return; + const property = properties.elements.find((el) => el.name === 'property' && el.attributes.name === propertyName); + if (!property) return null; - const version = superdocVersion.elements[0].elements[0].text; - return version; + return property.elements[0].elements[0].text; } catch (e) { - console.warn('Error getting Superdoc version', e); - return; + console.warn(`Error getting custom property ${propertyName}:`, e); + return null; } } - static updateDocumentVersion(docx = this.convertedXml, version = __APP_VERSION__) { + /** + * Generic method to set a stored custom property in docx + * @static + * @param {Object} docx - The docx object to store the property in + * @param {string} propertyName - Name of the property + * @param {string|Function} value - Value or function that returns the value + * @param {boolean} preserveExisting - If true, won't overwrite existing values + * @returns {string} The stored value + */ + static setStoredCustomProperty(docx, propertyName, value, preserveExisting = false) { const customLocation = 'docProps/custom.xml'; - if (!docx[customLocation]) { - docx[customLocation] = generateCustomXml(__APP_VERSION__); + if (!docx[customLocation]) docx[customLocation] = generateCustomXml(); + + const customXml = docx[customLocation]; + const properties = customXml.elements?.find((el) => el.name === 'Properties'); + if (!properties) return null; + if (!properties.elements) properties.elements = []; + + // Check if property already exists + let property = properties.elements.find((el) => el.name === 'property' && el.attributes.name === propertyName); + + if (property && preserveExisting) { + // Return existing value + return property.elements[0].elements[0].text; } - const customXml = docx['docProps/custom.xml']; - if (!customXml) return; + // Generate value if it's a function + const finalValue = typeof value === 'function' ? value() : value; - const properties = customXml.elements.find((el) => el.name === 'Properties'); - if (!properties.elements) properties.elements = []; + if (!property) { + // Get next available pid + const existingPids = properties.elements + .filter((el) => el.attributes?.pid) + .map((el) => parseInt(el.attributes.pid, 10)) // Add radix for clarity + .filter(Number.isInteger); // Use isInteger instead of isFinite since PIDs should be integers + const pid = existingPids.length > 0 ? Math.max(...existingPids) + 1 : 2; - const superdocVersion = properties.elements.find( - (el) => el.name === 'property' && el.attributes.name === 'SuperdocVersion', - ); - if (!superdocVersion) { - const newCustomXml = generateSuperdocVersion(); - properties.elements.push(newCustomXml); + property = { + type: 'element', + name: 'property', + attributes: { + name: propertyName, + fmtid: '{D5CDD505-2E9C-101B-9397-08002B2CF9AE}', + pid, + }, + elements: [ + { + type: 'element', + name: 'vt:lpwstr', + elements: [ + { + type: 'text', + text: finalValue, + }, + ], + }, + ], + }; + + properties.elements.push(property); } else { - superdocVersion.elements[0].elements[0].elements[0].text = version; + // Update existing property + property.elements[0].elements[0].text = finalValue; } - return docx; + return finalValue; + } + + static getStoredSuperdocVersion(docx) { + return SuperConverter.getStoredCustomProperty(docx, 'SuperdocVersion'); + } + + static setStoredSuperdocVersion(docx = this.convertedXml, version = __APP_VERSION__) { + return SuperConverter.setStoredCustomProperty(docx, 'SuperdocVersion', version, false); + } + + /** + * Get document GUID from docx files (static method) + * @static + * @param {Array} docx - Array of docx file objects + * @returns {string|null} The document GUID + */ + static extractDocumentGuid(docx) { + try { + const settingsXml = docx.find((doc) => doc.name === 'word/settings.xml'); + if (!settingsXml) return null; + + // Parse XML properly instead of regex + const converter = new SuperConverter(); + const settingsJson = converter.parseXmlToJson(settingsXml.content); + + // Navigate the parsed structure to find w15:docId + const settings = settingsJson.elements?.[0]; + if (!settings) return null; + + const docIdElement = settings.elements?.find((el) => el.name === 'w15:docId'); + if (docIdElement?.attributes?.['w15:val']) { + return docIdElement.attributes['w15:val'].replace(/[{}]/g, ''); + } + } catch { + // Continue to check custom property + } + + // Then check custom property + return SuperConverter.getStoredCustomProperty(docx, 'DocumentGuid'); + } + + /** + * Get the permanent document GUID + * @returns {string|null} The document GUID (only for modified documents) + */ + getDocumentGuid() { + return this.documentGuid; + } + + /** + * Get the SuperDoc version for this converter instance + * @returns {string|null} The SuperDoc version or null if not available + */ + getSuperdocVersion() { + if (this.docx) { + return SuperConverter.getStoredSuperdocVersion(this.docx); + } + return null; + } + + /** + * Resolve existing document GUID (synchronous) + */ + resolveDocumentGuid() { + // 1. Check Microsoft's docId (READ ONLY) + const microsoftGuid = this.getMicrosoftDocId(); + if (microsoftGuid) { + this.documentGuid = microsoftGuid; + return; + } + + // 2. Check our custom property + const customGuid = SuperConverter.getStoredCustomProperty(this.docx, 'DocumentGuid'); + if (customGuid) { + this.documentGuid = customGuid; + } + // Don't generate hash here - do it lazily when needed + } + + /** + * Get Microsoft's docId from settings.xml (READ ONLY) + */ + getMicrosoftDocId() { + this.getDocumentInternalId(); // Existing method + if (this.documentInternalId) { + return this.documentInternalId.replace(/[{}]/g, ''); + } + return null; + } + + /** + * Generate document hash for telemetry (async, lazy) + */ + async #generateDocumentHash() { + if (!this.fileSource) return `HASH-${Date.now()}`; + + try { + let buffer; + + if (Buffer.isBuffer(this.fileSource)) { + buffer = this.fileSource; + } else if (this.fileSource instanceof ArrayBuffer) { + buffer = Buffer.from(this.fileSource); + } else if (this.fileSource instanceof Blob || this.fileSource instanceof File) { + const arrayBuffer = await this.fileSource.arrayBuffer(); + buffer = Buffer.from(arrayBuffer); + } else { + return `HASH-${Date.now()}`; + } + + const hash = crc32(buffer); + return `HASH-${hash.toString('hex').toUpperCase()}`; + } catch (e) { + console.warn('Could not generate document hash:', e); + return `HASH-${Date.now()}`; + } + } + + /** + * Get document identifier (GUID or hash) - async for lazy hash generation + */ + async getDocumentIdentifier() { + if (this.documentGuid) { + return this.documentGuid; + } + + if (!this.documentHash && this.fileSource) { + this.documentHash = await this.#generateDocumentHash(); + } + + return this.documentHash; + } + + /** + * Promote from hash to GUID on first edit + */ + promoteToGuid() { + if (this.documentGuid) return this.documentGuid; + + this.documentGuid = this.getMicrosoftDocId() || uuidv4(); + this.documentModified = true; + this.documentHash = null; // Clear temporary hash + + // Note: GUID is stored to custom properties during export to avoid + // unnecessary XML modifications if the document is never saved + return this.documentGuid; } getDocumentDefaultStyles() { @@ -416,28 +617,22 @@ class SuperConverter { getDocumentInternalId() { const settingsLocation = 'word/settings.xml'; if (!this.convertedXml[settingsLocation]) { - this.convertedXml[settingsLocation] = SETTINGS_CUSTOM_XML; + // Don't create settings if it doesn't exist during read + return; } - const settings = Object.assign({}, this.convertedXml[settingsLocation]); - if (!settings.elements[0]?.elements?.length) { - const idElement = this.createDocumentIdElement(settings); - - settings.elements[0].elements = [idElement]; - if (!settings.elements[0].attributes['xmlns:w15']) { - settings.elements[0].attributes['xmlns:w15'] = 'http://schemas.microsoft.com/office/word/2012/wordml'; - } - this.convertedXml[settingsLocation] = settings; + const settings = this.convertedXml[settingsLocation]; + if (!settings.elements?.[0]?.elements?.length) { 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 + // Look for existing w15:docId only const w15DocId = settings.elements[0].elements.find((el) => el.name === 'w15:docId'); - this.documentInternalId = w15DocId?.attributes['w15:val']; + this.documentInternalId = w15DocId?.attributes?.['w15:val']; } createDocumentIdElement() { + // This should only be called when WRITING, never when reading const docId = uuidv4().toUpperCase(); this.documentInternalId = docId; @@ -558,8 +753,18 @@ class SuperConverter { // Update the rels table this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...headFootRels]); - // Store the SuperDoc version - storeSuperdocVersion(this.convertedXml); + // Store SuperDoc version + SuperConverter.setStoredSuperdocVersion(this.convertedXml); + + // Store document GUID if document was modified + if (this.documentModified || this.documentGuid) { + if (!this.documentGuid) { + this.documentGuid = this.getMicrosoftDocId() || uuidv4(); + } + + // Always store in custom.xml (never modify settings.xml) + SuperConverter.setStoredCustomProperty(this.convertedXml, 'DocumentGuid', this.documentGuid, true); + } // Update the numbering.xml this.#exportNumberingFile(params); @@ -816,60 +1021,21 @@ class SuperConverter { this.media = this.convertedXml.media; this.addedMedia = processedData; } -} - -function storeSuperdocVersion(docx) { - const customLocation = 'docProps/custom.xml'; - if (!docx[customLocation]) docx[customLocation] = generateCustomXml(); - - const customXml = docx[customLocation]; - const properties = customXml.elements.find((el) => el.name === 'Properties'); - if (!properties.elements) properties.elements = []; - const elements = properties.elements; - const cleanProperties = elements - .filter((prop) => typeof prop === 'object' && prop !== null) - .filter((prop) => { - const { attributes } = prop; - return attributes.name !== 'SuperdocVersion'; - }); - - let pid = 2; - try { - pid = cleanProperties.length ? Math.max(...elements.map((el) => el.attributes.pid)) + 1 : 2; - } catch {} + // Deprecated methods for backward compatibility + static getStoredSuperdocId(docx) { + console.warn('getStoredSuperdocId is deprecated, use getDocumentGuid instead'); + return SuperConverter.extractDocumentGuid(docx); + } - cleanProperties.push(generateSuperdocVersion(pid)); - properties.elements = cleanProperties; - return docx; + static updateDocumentVersion(docx, version) { + console.warn('updateDocumentVersion is deprecated, use setStoredSuperdocVersion instead'); + return SuperConverter.setStoredSuperdocVersion(docx, version); + } } function generateCustomXml() { return DEFAULT_CUSTOM_XML; } -function generateSuperdocVersion(pid = 2, version = __APP_VERSION__) { - return { - type: 'element', - name: 'property', - attributes: { - name: 'SuperdocVersion', - fmtid: '{D5CDD505-2E9C-101B-9397-08002B2CF9AE}', - pid, - }, - elements: [ - { - type: 'element', - name: 'vt:lpwstr', - elements: [ - { - type: 'text', - text: version, - }, - ], - }, - ], - }; -} - export { SuperConverter }; diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.test.js b/packages/super-editor/src/core/super-converter/SuperConverter.test.js new file mode 100644 index 0000000000..daba7e1eb2 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/SuperConverter.test.js @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SuperConverter } from './SuperConverter.js'; +import { v4 as uuidv4 } from 'uuid'; + +vi.mock('uuid', () => ({ + v4: vi.fn(() => 'test-uuid-1234'), +})); + +function hasTemporaryId(converter) { + // Has temporary ID if no GUID but has hash (or could generate one) + return !converter.documentGuid && !!(converter.documentHash || converter.fileSource); +} + +describe('SuperConverter Document GUID', () => { + let mockDocx; + let mockCustomXml; + let mockSettingsXml; + + beforeEach(() => { + vi.clearAllMocks(); + + // These need to match the actual file structure expected by SuperConverter + mockCustomXml = { + name: 'docProps/custom.xml', + content: ` + + `, + }; + + mockSettingsXml = { + name: 'word/settings.xml', + content: ` + + `, + }; + + // Add a minimal document.xml to prevent parsing errors + const mockDocumentXml = { + name: 'word/document.xml', + content: ` + + Test + `, + }; + + mockDocx = [mockCustomXml, mockSettingsXml, mockDocumentXml]; + }); + + describe('Document Identifier Resolution', () => { + it('prioritizes Microsoft docId from settings.xml', () => { + mockSettingsXml.content = ` + + + `; + + const converter = new SuperConverter({ docx: mockDocx }); + expect(converter.getDocumentGuid()).toBe('MICROSOFT-GUID-123'); + expect(hasTemporaryId(converter)).toBe(false); + }); + + it('uses custom DocumentGuid when no Microsoft GUID exists', () => { + // Override just the custom.xml with the GUID + const customDocx = [...mockDocx]; + customDocx[0] = { + name: 'docProps/custom.xml', + content: ` + + + CUSTOM-GUID-456 + + `, + }; + + const converter = new SuperConverter({ docx: customDocx }); + expect(converter.getDocumentGuid()).toBe('CUSTOM-GUID-456'); + expect(hasTemporaryId(converter)).toBe(false); + }); + + it('generates hash for unmodified document without GUID', async () => { + const fileSource = Buffer.from('test file content'); + const converter = new SuperConverter({ + docx: mockDocx, + fileSource, + }); + + // getDocumentIdentifier is now async + const identifier = await converter.getDocumentIdentifier(); + expect(identifier).toMatch(/^HASH-/); + expect(hasTemporaryId(converter)).toBe(true); + expect(converter.getDocumentGuid()).toBeNull(); + }); + }); + + describe('GUID Promotion', () => { + it('promotes hash to GUID when document is modified', async () => { + const fileSource = Buffer.from('test file content'); + const converter = new SuperConverter({ + docx: mockDocx, + fileSource, + }); + + // Generate hash first (async) + await converter.getDocumentIdentifier(); + + // Now check if has temporary ID + expect(hasTemporaryId(converter)).toBe(true); + + // Promote to GUID + const guid = converter.promoteToGuid(); + expect(guid).toBe('test-uuid-1234'); + expect(converter.getDocumentGuid()).toBe('test-uuid-1234'); + expect(hasTemporaryId(converter)).toBe(false); + expect(converter.documentModified).toBe(true); + }); + + it('does not re-promote if already has GUID', () => { + // Override just the custom.xml with the GUID + const customDocx = [...mockDocx]; + customDocx[0] = { + name: 'docProps/custom.xml', + content: ` + + + EXISTING-GUID + + `, + }; + + const converter = new SuperConverter({ docx: customDocx }); + const guid = converter.promoteToGuid(); + expect(guid).toBe('EXISTING-GUID'); + expect(uuidv4).not.toHaveBeenCalled(); + }); + }); + + describe('Static Methods', () => { + it('getDocumentGuid checks both sources', () => { + // Test Microsoft GUID + const docxWithMsGuid = [ + { + name: 'word/settings.xml', + content: + '', + }, + ]; + expect(SuperConverter.extractDocumentGuid(docxWithMsGuid)).toBe('MS-GUID'); + + // Test when no GUID exists + const guid = SuperConverter.extractDocumentGuid(mockDocx); + expect(guid).toBeNull(); + }); + }); + + describe('Version Methods', () => { + it('stores and retrieves version', () => { + const docx = { + 'docProps/custom.xml': { + elements: [ + { + name: 'Properties', + elements: [], + }, + ], + }, + }; + + // Set version + SuperConverter.setStoredSuperdocVersion(docx, '1.2.3'); + const prop = docx['docProps/custom.xml'].elements[0].elements[0]; + expect(prop.elements[0].elements[0].text).toBe('1.2.3'); + + // Get version + const version = SuperConverter.getStoredSuperdocVersion([ + { + name: 'docProps/custom.xml', + content: ` + + + 1.2.3 + + `, + }, + ]); + expect(version).toBe('1.2.3'); + }); + }); + + describe('Custom Properties', () => { + it('stores a custom property', () => { + const docx = { + 'docProps/custom.xml': { + elements: [ + { + name: 'Properties', + elements: [], + }, + ], + }, + }; + + SuperConverter.setStoredCustomProperty(docx, 'MyCustomProp', 'MyValue'); + const prop = docx['docProps/custom.xml'].elements[0].elements[0]; + expect(prop.attributes.name).toBe('MyCustomProp'); + expect(prop.elements[0].elements[0].text).toBe('MyValue'); + }); + + it('retrieves a custom property', () => { + const docx = { + name: 'docProps/custom.xml', + content: ` + + + MyValue + + `, + }; + const value = SuperConverter.getStoredCustomProperty([docx], 'MyCustomProp'); + expect(value).toBe('MyValue'); + }); + + it('returns null if custom property does not exist', () => { + const value = SuperConverter.getStoredCustomProperty( + [ + { + name: 'docProps/custom.xml', + content: ` + + `, + }, + ], + 'NonExistentProp', + ); + expect(value).toBeNull(); + }); + }); + + describe('Backward Compatibility', () => { + it('deprecated methods show warnings', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + SuperConverter.updateDocumentVersion(mockDocx, '1.0.0'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'updateDocumentVersion is deprecated, use setStoredSuperdocVersion instead', + ); + + consoleWarnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/super-editor/src/core/super-converter/exporter-docx-defs.js b/packages/super-editor/src/core/super-converter/exporter-docx-defs.js index eb7de58aba..539be538ef 100644 --- a/packages/super-editor/src/core/super-converter/exporter-docx-defs.js +++ b/packages/super-editor/src/core/super-converter/exporter-docx-defs.js @@ -62,19 +62,6 @@ export const DEFAULT_CUSTOM_XML = { ], }; -export const SETTINGS_CUSTOM_XML = { - elements: [ - { - type: 'element', - name: 'w:settings', - attributes: { - 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', - }, - elements: [], - }, - ], -}; - export const COMMENT_REF = { type: 'element', name: 'w:r', 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 8452f24bcd..87925b64c8 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 @@ -61,17 +61,30 @@ export const createDocumentJson = (docx, converter, editor) => { }; }); - converter.telemetry.trackFileStructure( - { - totalFiles: files.length, - maxDepth: Math.max(...files.map((f) => f.fileDepth)), - totalNodes: 0, - files, - }, - converter.fileSource, - converter.documentId, - converter.documentInternalId, - ); + const trackStructure = (documentIdentifier = null) => + converter.telemetry.trackFileStructure( + { + totalFiles: files.length, + maxDepth: Math.max(...files.map((f) => f.fileDepth)), + totalNodes: 0, + files, + }, + converter.fileSource, + converter.documentGuid ?? converter.documentId ?? null, + documentIdentifier ?? converter.documentId ?? null, + converter.documentInternalId, + ); + + try { + const identifierResult = converter.getDocumentIdentifier?.(); + if (identifierResult && typeof identifierResult.then === 'function') { + identifierResult.then(trackStructure).catch(() => trackStructure()); + } else { + trackStructure(identifierResult); + } + } catch (error) { + trackStructure(); + } } const nodeListHandler = defaultNodeListHandler(); diff --git a/packages/super-editor/src/tests/import/docxImporter.test.js b/packages/super-editor/src/tests/import/docxImporter.test.js index 26bf4df126..61611efbde 100644 --- a/packages/super-editor/src/tests/import/docxImporter.test.js +++ b/packages/super-editor/src/tests/import/docxImporter.test.js @@ -211,4 +211,41 @@ describe('createDocumentJson', () => { expect(horizontalRules).toHaveLength(3); }); + + it('passes GUID, identifier, and internal id to telemetry in correct order', async () => { + const simpleDocXml = + 'Telemetry'; + + const docx = { + 'word/document.xml': parseXmlToJson(simpleDocXml), + }; + + const trackFileStructure = vi.fn(); + const converter = { + telemetry: { + trackFileStructure, + trackStatistic: vi.fn(), + trackUsage: vi.fn(), + }, + fileSource: { name: 'telemetry.docx', size: 1234 }, + documentGuid: 'GUID-1234', + documentId: 'legacy-id', + documentInternalId: '{ABC-123}', + getDocumentIdentifier: vi.fn().mockResolvedValue('HASH-5678'), + }; + + const editor = { options: {}, emit: vi.fn() }; + + createDocumentJson(docx, converter, editor); + + await Promise.resolve(); + + expect(trackFileStructure).toHaveBeenCalledWith( + expect.objectContaining({ totalFiles: 1 }), + converter.fileSource, + converter.documentGuid, + 'HASH-5678', + converter.documentInternalId, + ); + }); }); diff --git a/shared/common/Telemetry.js b/shared/common/Telemetry.js index 7953cfbb75..37ba599154 100644 --- a/shared/common/Telemetry.js +++ b/shared/common/Telemetry.js @@ -3,12 +3,11 @@ * @property {string} [licenseKey] - License key for telemetry service * @property {boolean} [enabled=true] - Whether telemetry is enabled * @property {string} endpoint - service endpoint - * @property {string} superdocId - SuperDoc id + * @property {string} [documentGuid] - Document GUID (permanent identifier) + * @property {string} [documentIdentifier] - Document identifier (can be GUID or hash) * @property {string} superdocVersion - SuperDoc version */ -import crc32 from 'buffer-crc32'; - function randomBytes(length) { const array = new Uint8Array(length); for (let i = 0; i < length; i++) { @@ -22,7 +21,10 @@ class Telemetry { enabled; /** @type {string} */ - superdocId; + documentGuid; + + /** @type {string} */ + documentIdentifier; /** @type {string} */ superdocVersion; @@ -76,8 +78,10 @@ class Telemetry { this.licenseKey = config.licenseKey ?? Telemetry.COMMUNITY_LICENSE_KEY; this.endpoint = config.endpoint ?? Telemetry.DEFAULT_ENDPOINT; - this.superdocId = config.superdocId; + // Update naming to match new system + this.documentGuid = config.documentGuid; // Changed from superdocId + this.documentIdentifier = config.documentIdentifier; // New: can be GUID or hash this.superdocVersion = config.superdocVersion; this.sessionId = this.generateId(); } @@ -112,7 +116,8 @@ class Telemetry { type: 'usage', timestamp: new Date().toISOString(), sessionId: this.sessionId, - superdocId: this.superdocId, + documentGuid: this.documentGuid, // Updated field name + documentIdentifier: this.documentIdentifier, // Include both superdocVersion: this.superdocVersion, file: this.documentInfo, browser: this.getBrowserInfo(), @@ -185,13 +190,15 @@ class Telemetry { * Track file structure * @param {Object} structure - File structure information * @param {File} fileSource - original file - * @param {String} documentId - document ID - * @param {string} internalId - document ID form settings.xml + * @param {String} documentId - document GUID + * @param {string} documentIdentifier - document identifier (GUID or hash) + * @param {string} internalId - document ID from settings.xml */ - async trackFileStructure(structure, fileSource, documentId, internalId) { + async trackFileStructure(structure, fileSource, documentId, documentIdentifier, internalId) { this.fileStructure = structure; this.documentInfo = await this.processDocument(fileSource, { - id: documentId, + guid: documentId, // Updated parameter name + identifier: documentIdentifier, // New parameter internalId: internalId, }); } @@ -208,37 +215,17 @@ class Telemetry { return {}; } - let hash = ''; - try { - hash = await this.generateCrc32Hash(file); - } catch (error) { - console.error('Failed to generate file hash:', error); - } - return { - id: options.id, + guid: options.guid, // Updated from 'id' + identifier: options.identifier, // New field name: file.name, size: file.size, - crc32: hash, lastModified: file.lastModified ? new Date(file.lastModified).toISOString() : null, type: file.type || 'docx', - internalId: options.internalId, + internalId: options.internalId, // Microsoft's GUID if present }; } - /** - * 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); - return hashBuffer.toString('hex'); - } - isTelemetryDataChanged() { const initialStatistics = { nodeTypes: {},