-
Notifications
You must be signed in to change notification settings - Fork 61
SD-1588 - Added setDocumentProtection command
#1808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| // @ts-check | ||
| import { carbonCopy } from '@core/utilities/carbonCopy.js'; | ||
|
|
||
| const SETTINGS_PATH = 'word/settings.xml'; | ||
| const DOC_PROTECTION_NODE = 'w:documentProtection'; | ||
|
|
||
| const PROTECTION_VALUE_MAP = Object.freeze({ | ||
| noProtection: null, | ||
| allowOnlyRevisions: 'trackedChanges', | ||
| allowOnlyComments: 'comments', | ||
| allowOnlyFormFields: 'forms', | ||
| allowOnlyReading: 'readOnly', | ||
| }); | ||
|
|
||
| const DEFAULT_MODE = 'noProtection'; | ||
|
|
||
| /** | ||
| * Normalize the caller provided document protection mode. | ||
| * @param {string} mode | ||
| * @returns {keyof typeof PROTECTION_VALUE_MAP} | ||
| */ | ||
| function normalizeMode(mode = '') { | ||
| const normalized = typeof mode === 'string' ? mode.trim() : ''; | ||
| console.log('normalized', normalized); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like that's debug - remove? |
||
| return /** @type {keyof typeof PROTECTION_VALUE_MAP} */ (normalized || DEFAULT_MODE); | ||
| } | ||
|
|
||
| /** | ||
| * Build the document protection XML node. | ||
| * @param {string} editValue | ||
| * @returns {Object} | ||
| */ | ||
| function createDocProtectionNode(editValue) { | ||
| return { | ||
| type: 'element', | ||
| name: DOC_PROTECTION_NODE, | ||
| attributes: { | ||
| 'w:edit': editValue, | ||
| 'w:enforcement': '1', | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Update the DOCX settings to enforce or remove document protection. | ||
| * @param {'noProtection' | 'allowOnlyRevisions' | 'allowOnlyComments' | 'allowOnlyFormFields' | 'allowOnlyReading'} mode | ||
| * @returns {import('./types').Command} | ||
| */ | ||
| export const setProtectionMode = (mode = 'noProtection') => { | ||
| return ({ editor }) => { | ||
| const convertedXml = editor?.converter?.convertedXml; | ||
| if (!convertedXml) return false; | ||
|
|
||
| const normalizedMode = normalizeMode(mode); | ||
| console.log('normalizedMode', normalizedMode); | ||
| console.log('PROTECTION_VALUE_MAP', PROTECTION_VALUE_MAP, normalizedMode in PROTECTION_VALUE_MAP); | ||
| if (!(normalizedMode in PROTECTION_VALUE_MAP)) return false; | ||
|
Comment on lines
+55
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The validation uses Useful? React with 👍 / 👎. |
||
| const settingsXml = convertedXml[SETTINGS_PATH]; | ||
| const settingsRoot = settingsXml?.elements?.[0]; | ||
| if (!settingsRoot) return false; | ||
|
|
||
| const updatedSettings = carbonCopy(settingsXml); | ||
| const updatedRoot = updatedSettings.elements?.[0]; | ||
| if (!updatedRoot) return false; | ||
|
|
||
| if (!Array.isArray(updatedRoot.elements)) { | ||
| updatedRoot.elements = []; | ||
| } | ||
|
|
||
| const elements = updatedRoot.elements; | ||
| const existingIndex = elements.findIndex((node) => node?.name === DOC_PROTECTION_NODE); | ||
| if (existingIndex !== -1) { | ||
| elements.splice(existingIndex, 1); | ||
| } | ||
|
|
||
| const mappedValue = PROTECTION_VALUE_MAP[normalizedMode]; | ||
| if (mappedValue) { | ||
| const protectionNode = createDocProtectionNode(mappedValue); | ||
| const insertIndex = existingIndex >= 0 ? existingIndex : 0; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When no existing protection node exists, this inserts at index 0 which puts it before OOXML spec says |
||
| elements.splice(insertIndex, 0, protectionNode); | ||
| } | ||
|
|
||
| convertedXml[SETTINGS_PATH] = updatedSettings; | ||
| editor.updateInternalXmlFile(SETTINGS_PATH, updatedSettings); | ||
| return true; | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { setProtectionMode } from './setProtectionMode.js'; | ||
|
|
||
| const SETTINGS_PATH = 'word/settings.xml'; | ||
|
|
||
| const createBaseSettings = () => ({ | ||
| elements: [ | ||
| { | ||
| type: 'element', | ||
| name: 'w:settings', | ||
| attributes: { | ||
| 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', | ||
| }, | ||
| elements: [ | ||
| { | ||
| type: 'element', | ||
| name: 'w:zoom', | ||
| attributes: { 'w:percent': '120' }, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| const clone = (obj) => JSON.parse(JSON.stringify(obj)); | ||
|
|
||
| const buildEditor = (settings = createBaseSettings()) => { | ||
| const editor = { | ||
| converter: { | ||
| convertedXml: { | ||
| [SETTINGS_PATH]: clone(settings), | ||
| }, | ||
| }, | ||
| updateInternalXmlFile: vi.fn(), | ||
| }; | ||
|
|
||
| return editor; | ||
| }; | ||
|
|
||
| describe('setProtectionMode command', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('inserts docProtection node for the requested mode', () => { | ||
| const editor = buildEditor(); | ||
| const command = setProtectionMode('allowOnlyReading'); | ||
| const result = command({ editor }); | ||
|
|
||
| expect(result).toBe(true); | ||
| const updatedSettings = editor.converter.convertedXml[SETTINGS_PATH]; | ||
| const rootElements = updatedSettings.elements[0].elements; | ||
| expect(rootElements[0].name).toBe('w:documentProtection'); | ||
| expect(rootElements[0].attributes).toMatchObject({ 'w:edit': 'readOnly', 'w:enforcement': '1' }); | ||
| expect(editor.updateInternalXmlFile).toHaveBeenCalledWith(SETTINGS_PATH, updatedSettings); | ||
| }); | ||
|
|
||
| it('supports AllowOnlyFormFields casing and replaces existing node', () => { | ||
| const base = createBaseSettings(); | ||
| base.elements[0].elements.unshift({ | ||
| type: 'element', | ||
| name: 'w:documentProtection', | ||
| attributes: { 'w:edit': 'comments', 'w:enforcement': '1' }, | ||
| }); | ||
|
|
||
| const editor = buildEditor(base); | ||
| const command = setProtectionMode('allowOnlyFormFields'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test name says it tests casing but only uses one case |
||
| const success = command({ editor }); | ||
|
|
||
| expect(success).toBe(true); | ||
| const updated = editor.converter.convertedXml[SETTINGS_PATH].elements[0].elements; | ||
| expect(updated[0].attributes['w:edit']).toBe('forms'); | ||
| expect(updated).toHaveLength(base.elements[0].elements.length); | ||
| }); | ||
|
|
||
| it('removes docProtection node when switching to noProtection', () => { | ||
| const base = createBaseSettings(); | ||
| base.elements[0].elements.unshift({ | ||
| type: 'element', | ||
| name: 'w:documentProtection', | ||
| attributes: { 'w:edit': 'trackedChanges', 'w:enforcement': '1' }, | ||
| }); | ||
|
|
||
| const editor = buildEditor(base); | ||
| const removed = setProtectionMode('noProtection')({ editor }); | ||
|
|
||
| expect(removed).toBe(true); | ||
| const nodes = editor.converter.convertedXml[SETTINGS_PATH].elements[0].elements; | ||
| expect(nodes.find((node) => node.name === 'w:documentProtection')).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns false when settings.xml is missing', () => { | ||
| const editor = buildEditor(); | ||
| delete editor.converter.convertedXml[SETTINGS_PATH]; | ||
| const result = setProtectionMode('allowOnlyComments')({ editor }); | ||
| expect(result).toBe(false); | ||
| expect(editor.updateInternalXmlFile).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rejects invalid modes without mutating the XML', () => { | ||
| const editor = buildEditor(); | ||
| const before = clone(editor.converter.convertedXml[SETTINGS_PATH]); | ||
| const result = setProtectionMode('unsupported-mode')({ editor }); | ||
|
|
||
| expect(result).toBe(false); | ||
| expect(editor.converter.convertedXml[SETTINGS_PATH]).toEqual(before); | ||
| expect(editor.updateInternalXmlFile).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Passing
null/undefinedhere becomes empty string, which then falls back tonoProtectionvia the default.Means invalid input silently removes protection instead of erroring.
Maybe we should return false for non-strings instead?