From 2e8d6ce7d9aad7569cd8bfbdd1126c869a78d379 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 10 Oct 2024 12:03:24 +0100 Subject: [PATCH 01/72] TS: Coverted util service --- resources/js/components/add-remove-rows.js | 2 +- resources/js/components/auto-suggest.js | 2 +- resources/js/components/dropdown-search.js | 2 +- resources/js/components/global-search.js | 2 +- resources/js/components/page-display.js | 2 +- resources/js/components/page-editor.js | 2 +- resources/js/markdown/codemirror.js | 2 +- resources/js/services/{util.js => util.ts} | 54 +++++++++---------- resources/js/wysiwyg-tinymce/plugin-drawio.js | 2 +- 9 files changed, 32 insertions(+), 38 deletions(-) rename resources/js/services/{util.js => util.ts} (67%) diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js index 3213c4835aa..488654279b4 100644 --- a/resources/js/components/add-remove-rows.js +++ b/resources/js/components/add-remove-rows.js @@ -1,5 +1,5 @@ import {onChildEvent} from '../services/dom'; -import {uniqueId} from '../services/util'; +import {uniqueId} from '../services/util.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js index 2eede241c69..07711312f1d 100644 --- a/resources/js/components/auto-suggest.js +++ b/resources/js/components/auto-suggest.js @@ -1,4 +1,4 @@ -import {escapeHtml} from '../services/util'; +import {escapeHtml} from '../services/util.ts'; import {onChildEvent} from '../services/dom'; import {Component} from './component'; import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js index 2344619f5e9..787e11c875d 100644 --- a/resources/js/components/dropdown-search.js +++ b/resources/js/components/dropdown-search.js @@ -1,4 +1,4 @@ -import {debounce} from '../services/util'; +import {debounce} from '../services/util.ts'; import {transitionHeight} from '../services/animations'; import {Component} from './component'; diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index 798bd7aacb0..44c0d02f9d4 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -1,5 +1,5 @@ import {htmlToDom} from '../services/dom'; -import {debounce} from '../services/util'; +import {debounce} from '../services/util.ts'; import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; import {Component} from './component'; diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index 1e13ae38800..ff9d68c7a5a 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -1,5 +1,5 @@ import * as DOM from '../services/dom'; -import {scrollAndHighlightElement} from '../services/util'; +import {scrollAndHighlightElement} from '../services/util.ts'; import {Component} from './component'; function toggleAnchorHighlighting(elementId, shouldHighlight) { diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 2160675529b..9450444ca41 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -1,5 +1,5 @@ import {onSelect} from '../services/dom'; -import {debounce} from '../services/util'; +import {debounce} from '../services/util.ts'; import {Component} from './component'; import {utcTimeStampToLocalTime} from '../services/dates.ts'; diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/codemirror.js index a6332cbb844..664767605b8 100644 --- a/resources/js/markdown/codemirror.js +++ b/resources/js/markdown/codemirror.js @@ -1,5 +1,5 @@ import {provideKeyBindings} from './shortcuts'; -import {debounce} from '../services/util'; +import {debounce} from '../services/util.ts'; import {Clipboard} from '../services/clipboard.ts'; /** diff --git a/resources/js/services/util.js b/resources/js/services/util.ts similarity index 67% rename from resources/js/services/util.js rename to resources/js/services/util.ts index 1264d105860..7f684dd4247 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.ts @@ -4,37 +4,39 @@ * N milliseconds. If `immediate` is passed, trigger the function on the * leading edge, instead of the trailing. * @attribution https://davidwalsh.name/javascript-debounce-function - * @param {Function} func - * @param {Number} waitMs - * @param {Boolean} immediate - * @returns {Function} */ -export function debounce(func, waitMs, immediate) { - let timeout; - return function debouncedWrapper(...args) { - const context = this; +export function debounce(func: Function, waitMs: number, immediate: boolean): Function { + let timeout: number|null = null; + return function debouncedWrapper(this: any, ...args: any[]) { + const context: any = this; const later = function debouncedTimeout() { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, waitMs); + if (timeout) { + clearTimeout(timeout); + } + timeout = window.setTimeout(later, waitMs); if (callNow) func.apply(context, args); }; } +function isDetailsElement(element: HTMLElement): element is HTMLDetailsElement { + return element.nodeName === 'DETAILS'; +} + /** - * Scroll and highlight an element. - * @param {HTMLElement} element + * Scroll-to and highlight an element. */ -export function scrollAndHighlightElement(element) { +export function scrollAndHighlightElement(element: HTMLElement): void { if (!element) return; + // Open up parent
elements if within let parent = element; while (parent.parentElement) { parent = parent.parentElement; - if (parent.nodeName === 'DETAILS' && !parent.open) { + if (isDetailsElement(parent) && !parent.open) { parent.open = true; } } @@ -44,15 +46,15 @@ export function scrollAndHighlightElement(element) { const highlight = getComputedStyle(document.body).getPropertyValue('--color-link'); element.style.outline = `2px dashed ${highlight}`; element.style.outlineOffset = '5px'; - element.style.transition = null; + element.style.removeProperty('transition'); setTimeout(() => { element.style.transition = 'outline linear 3s'; element.style.outline = '2px dashed rgba(0, 0, 0, 0)'; const listener = () => { element.removeEventListener('transitionend', listener); - element.style.transition = null; - element.style.outline = null; - element.style.outlineOffset = null; + element.style.removeProperty('transition'); + element.style.removeProperty('outline'); + element.style.removeProperty('outlineOffset'); }; element.addEventListener('transitionend', listener); }, 1000); @@ -61,10 +63,8 @@ export function scrollAndHighlightElement(element) { /** * Escape any HTML in the given 'unsafe' string. * Take from https://stackoverflow.com/a/6234804. - * @param {String} unsafe - * @returns {string} */ -export function escapeHtml(unsafe) { +export function escapeHtml(unsafe: string): string { return unsafe .replace(/&/g, '&') .replace(/ (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); @@ -86,10 +84,8 @@ export function uniqueId() { /** * Generate a random smaller unique ID. - * - * @returns {string} */ -export function uniqueIdSmall() { +export function uniqueIdSmall(): string { // eslint-disable-next-line no-bitwise const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); return S4(); @@ -97,10 +93,8 @@ export function uniqueIdSmall() { /** * Create a promise that resolves after the given time. - * @param {int} timeMs - * @returns {Promise} */ -export function wait(timeMs) { +export function wait(timeMs: number): Promise { return new Promise(res => { setTimeout(res, timeMs); }); diff --git a/resources/js/wysiwyg-tinymce/plugin-drawio.js b/resources/js/wysiwyg-tinymce/plugin-drawio.js index 342cac0af74..197c50b0e44 100644 --- a/resources/js/wysiwyg-tinymce/plugin-drawio.js +++ b/resources/js/wysiwyg-tinymce/plugin-drawio.js @@ -1,5 +1,5 @@ import * as DrawIO from '../services/drawio.ts'; -import {wait} from '../services/util'; +import {wait} from '../services/util.ts'; let pageEditor = null; let currentNode = null; From f41c02cbd7e705155b5b4fdbf8dfb3dc09a445d1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 11 Oct 2024 15:19:19 +0100 Subject: [PATCH 02/72] TS: Converted app file and animations service Extracted functions out of app file during changes to clean up. Altered animation function to use normal css prop names instead of JS CSS prop names. --- resources/js/app.js | 33 -------- resources/js/app.ts | 23 ++++++ resources/js/components/chapter-contents.js | 2 +- resources/js/components/collapsible.js | 2 +- resources/js/components/dropdown-search.js | 2 +- resources/js/components/expand-toggle.js | 2 +- .../js/components/{index.js => index.ts} | 0 resources/js/components/popup.js | 2 +- resources/js/global.d.ts | 2 + .../services/{animations.js => animations.ts} | 78 ++++++++----------- resources/js/services/util.ts | 46 +++++++++++ 11 files changed, 108 insertions(+), 84 deletions(-) delete mode 100644 resources/js/app.js create mode 100644 resources/js/app.ts rename resources/js/components/{index.js => index.ts} (100%) rename resources/js/services/{animations.js => animations.ts} (63%) diff --git a/resources/js/app.js b/resources/js/app.js deleted file mode 100644 index 5f4902f866f..00000000000 --- a/resources/js/app.js +++ /dev/null @@ -1,33 +0,0 @@ -import {EventManager} from './services/events.ts'; -import {HttpManager} from './services/http.ts'; -import {Translator} from './services/translations.ts'; -import * as componentMap from './components'; -import {ComponentStore} from './services/components.ts'; - -// eslint-disable-next-line no-underscore-dangle -window.__DEV__ = false; - -// Url retrieval function -window.baseUrl = function baseUrl(path) { - let targetPath = path; - let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content'); - if (basePath[basePath.length - 1] === '/') basePath = basePath.slice(0, basePath.length - 1); - if (targetPath[0] === '/') targetPath = targetPath.slice(1); - return `${basePath}/${targetPath}`; -}; - -window.importVersioned = function importVersioned(moduleName) { - const version = document.querySelector('link[href*="/dist/styles.css?version="]').href.split('?version=').pop(); - const importPath = window.baseUrl(`dist/${moduleName}.js?version=${version}`); - return import(importPath); -}; - -// Set events, http & translation services on window -window.$http = new HttpManager(); -window.$events = new EventManager(); -window.$trans = new Translator(); - -// Load & initialise components -window.$components = new ComponentStore(); -window.$components.register(componentMap); -window.$components.init(); diff --git a/resources/js/app.ts b/resources/js/app.ts new file mode 100644 index 00000000000..141a50ef5dc --- /dev/null +++ b/resources/js/app.ts @@ -0,0 +1,23 @@ +import {EventManager} from './services/events'; +import {HttpManager} from './services/http'; +import {Translator} from './services/translations'; +import * as componentMap from './components/index'; +import {ComponentStore} from './services/components'; +import {baseUrl, importVersioned} from "./services/util"; + +// eslint-disable-next-line no-underscore-dangle +window.__DEV__ = false; + +// Make common important util functions global +window.baseUrl = baseUrl; +window.importVersioned = importVersioned; + +// Setup events, http & translation services +window.$http = new HttpManager(); +window.$events = new EventManager(); +window.$trans = new Translator(); + +// Load & initialise components +window.$components = new ComponentStore(); +window.$components.register(componentMap); +window.$components.init(); diff --git a/resources/js/components/chapter-contents.js b/resources/js/components/chapter-contents.js index 7c6480a1af0..6b0707bdd05 100644 --- a/resources/js/components/chapter-contents.js +++ b/resources/js/components/chapter-contents.js @@ -1,4 +1,4 @@ -import {slideUp, slideDown} from '../services/animations'; +import {slideUp, slideDown} from '../services/animations.ts'; import {Component} from './component'; export class ChapterContents extends Component { diff --git a/resources/js/components/collapsible.js b/resources/js/components/collapsible.js index 6f740ed7163..7b6fa79fb3b 100644 --- a/resources/js/components/collapsible.js +++ b/resources/js/components/collapsible.js @@ -1,4 +1,4 @@ -import {slideDown, slideUp} from '../services/animations'; +import {slideDown, slideUp} from '../services/animations.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js index 787e11c875d..fcbabc022a7 100644 --- a/resources/js/components/dropdown-search.js +++ b/resources/js/components/dropdown-search.js @@ -1,5 +1,5 @@ import {debounce} from '../services/util.ts'; -import {transitionHeight} from '../services/animations'; +import {transitionHeight} from '../services/animations.ts'; import {Component} from './component'; export class DropdownSearch extends Component { diff --git a/resources/js/components/expand-toggle.js b/resources/js/components/expand-toggle.js index 0d2018b9da2..29173a05829 100644 --- a/resources/js/components/expand-toggle.js +++ b/resources/js/components/expand-toggle.js @@ -1,4 +1,4 @@ -import {slideUp, slideDown} from '../services/animations'; +import {slideUp, slideDown} from '../services/animations.ts'; import {Component} from './component'; export class ExpandToggle extends Component { diff --git a/resources/js/components/index.js b/resources/js/components/index.ts similarity index 100% rename from resources/js/components/index.js rename to resources/js/components/index.ts diff --git a/resources/js/components/popup.js b/resources/js/components/popup.js index 6627365483e..edd42803733 100644 --- a/resources/js/components/popup.js +++ b/resources/js/components/popup.js @@ -1,4 +1,4 @@ -import {fadeIn, fadeOut} from '../services/animations'; +import {fadeIn, fadeOut} from '../services/animations.ts'; import {onSelect} from '../services/dom'; import {Component} from './component'; diff --git a/resources/js/global.d.ts b/resources/js/global.d.ts index e505c96e0d4..b637c97c1b9 100644 --- a/resources/js/global.d.ts +++ b/resources/js/global.d.ts @@ -7,10 +7,12 @@ declare global { const __DEV__: boolean; interface Window { + __DEV__: boolean; $components: ComponentStore; $events: EventManager; $trans: Translator; $http: HttpManager; baseUrl: (path: string) => string; + importVersioned: (module: string) => Promise; } } \ No newline at end of file diff --git a/resources/js/services/animations.js b/resources/js/services/animations.ts similarity index 63% rename from resources/js/services/animations.js rename to resources/js/services/animations.ts index bc983c8072a..adf4cb3c932 100644 --- a/resources/js/services/animations.js +++ b/resources/js/services/animations.ts @@ -1,30 +1,30 @@ /** * Used in the function below to store references of clean-up functions. * Used to ensure only one transitionend function exists at any time. - * @type {WeakMap} */ -const animateStylesCleanupMap = new WeakMap(); +const animateStylesCleanupMap: WeakMap = new WeakMap(); /** * Animate the css styles of an element using FLIP animation techniques. - * Styles must be an object where the keys are style properties, camelcase, and the values + * Styles must be an object where the keys are style rule names and the values * are an array of two items in the format [initialValue, finalValue] - * @param {Element} element - * @param {Object} styles - * @param {Number} animTime - * @param {Function} onComplete */ -function animateStyles(element, styles, animTime = 400, onComplete = null) { +function animateStyles( + element: HTMLElement, + styles: Record, + animTime: number = 400, + onComplete: Function | null = null +): void { const styleNames = Object.keys(styles); for (const style of styleNames) { - element.style[style] = styles[style][0]; + element.style.setProperty(style, styles[style][0]); } const cleanup = () => { for (const style of styleNames) { - element.style[style] = null; + element.style.removeProperty(style); } - element.style.transition = null; + element.style.removeProperty('transition'); element.removeEventListener('transitionend', cleanup); animateStylesCleanupMap.delete(element); if (onComplete) onComplete(); @@ -33,7 +33,7 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) { setTimeout(() => { element.style.transition = `all ease-in-out ${animTime}ms`; for (const style of styleNames) { - element.style[style] = styles[style][1]; + element.style.setProperty(style, styles[style][1]); } element.addEventListener('transitionend', cleanup); @@ -43,9 +43,8 @@ function animateStyles(element, styles, animTime = 400, onComplete = null) { /** * Run the active cleanup action for the given element. - * @param {Element} element */ -function cleanupExistingElementAnimation(element) { +function cleanupExistingElementAnimation(element: Element) { if (animateStylesCleanupMap.has(element)) { const oldCleanup = animateStylesCleanupMap.get(element); oldCleanup(); @@ -54,15 +53,12 @@ function cleanupExistingElementAnimation(element) { /** * Fade in the given element. - * @param {Element} element - * @param {Number} animTime - * @param {Function|null} onComplete */ -export function fadeIn(element, animTime = 400, onComplete = null) { +export function fadeIn(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void { cleanupExistingElementAnimation(element); element.style.display = 'block'; animateStyles(element, { - opacity: ['0', '1'], + 'opacity': ['0', '1'], }, animTime, () => { if (onComplete) onComplete(); }); @@ -70,14 +66,11 @@ export function fadeIn(element, animTime = 400, onComplete = null) { /** * Fade out the given element. - * @param {Element} element - * @param {Number} animTime - * @param {Function|null} onComplete */ -export function fadeOut(element, animTime = 400, onComplete = null) { +export function fadeOut(element: HTMLElement, animTime: number = 400, onComplete: Function | null = null): void { cleanupExistingElementAnimation(element); animateStyles(element, { - opacity: ['1', '0'], + 'opacity': ['1', '0'], }, animTime, () => { element.style.display = 'none'; if (onComplete) onComplete(); @@ -86,20 +79,18 @@ export function fadeOut(element, animTime = 400, onComplete = null) { /** * Hide the element by sliding the contents upwards. - * @param {Element} element - * @param {Number} animTime */ -export function slideUp(element, animTime = 400) { +export function slideUp(element: HTMLElement, animTime: number = 400) { cleanupExistingElementAnimation(element); const currentHeight = element.getBoundingClientRect().height; const computedStyles = getComputedStyle(element); const currentPaddingTop = computedStyles.getPropertyValue('padding-top'); const currentPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const animStyles = { - maxHeight: [`${currentHeight}px`, '0px'], - overflow: ['hidden', 'hidden'], - paddingTop: [currentPaddingTop, '0px'], - paddingBottom: [currentPaddingBottom, '0px'], + 'max-height': [`${currentHeight}px`, '0px'], + 'overflow': ['hidden', 'hidden'], + 'padding-top': [currentPaddingTop, '0px'], + 'padding-bottom': [currentPaddingBottom, '0px'], }; animateStyles(element, animStyles, animTime, () => { @@ -109,10 +100,8 @@ export function slideUp(element, animTime = 400) { /** * Show the given element by expanding the contents. - * @param {Element} element - Element to animate - * @param {Number} animTime - Animation time in ms */ -export function slideDown(element, animTime = 400) { +export function slideDown(element: HTMLElement, animTime: number = 400) { cleanupExistingElementAnimation(element); element.style.display = 'block'; const targetHeight = element.getBoundingClientRect().height; @@ -120,10 +109,10 @@ export function slideDown(element, animTime = 400) { const targetPaddingTop = computedStyles.getPropertyValue('padding-top'); const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const animStyles = { - maxHeight: ['0px', `${targetHeight}px`], - overflow: ['hidden', 'hidden'], - paddingTop: ['0px', targetPaddingTop], - paddingBottom: ['0px', targetPaddingBottom], + 'max-height': ['0px', `${targetHeight}px`], + 'overflow': ['hidden', 'hidden'], + 'padding-top': ['0px', targetPaddingTop], + 'padding-bottom': ['0px', targetPaddingBottom], }; animateStyles(element, animStyles, animTime); @@ -134,11 +123,8 @@ export function slideDown(element, animTime = 400) { * Call with first state, and you'll receive a function in return. * Call the returned function in the second state to animate between those two states. * If animating to/from 0-height use the slide-up/slide down as easier alternatives. - * @param {Element} element - Element to animate - * @param {Number} animTime - Animation time in ms - * @returns {function} - Function to run in second state to trigger animation. */ -export function transitionHeight(element, animTime = 400) { +export function transitionHeight(element: HTMLElement, animTime: number = 400): () => void { const startHeight = element.getBoundingClientRect().height; const initialComputedStyles = getComputedStyle(element); const startPaddingTop = initialComputedStyles.getPropertyValue('padding-top'); @@ -151,10 +137,10 @@ export function transitionHeight(element, animTime = 400) { const targetPaddingTop = computedStyles.getPropertyValue('padding-top'); const targetPaddingBottom = computedStyles.getPropertyValue('padding-bottom'); const animStyles = { - height: [`${startHeight}px`, `${targetHeight}px`], - overflow: ['hidden', 'hidden'], - paddingTop: [startPaddingTop, targetPaddingTop], - paddingBottom: [startPaddingBottom, targetPaddingBottom], + 'height': [`${startHeight}px`, `${targetHeight}px`], + 'overflow': ['hidden', 'hidden'], + 'padding-top': [startPaddingTop, targetPaddingTop], + 'padding-bottom': [startPaddingBottom, targetPaddingBottom], }; animateStyles(element, animStyles, animTime); diff --git a/resources/js/services/util.ts b/resources/js/services/util.ts index 7f684dd4247..c5a5d2db804 100644 --- a/resources/js/services/util.ts +++ b/resources/js/services/util.ts @@ -99,3 +99,49 @@ export function wait(timeMs: number): Promise { setTimeout(res, timeMs); }); } + +/** + * Generate a full URL from the given relative URL, using a base + * URL defined in the head of the page. + */ +export function baseUrl(path: string): string { + let targetPath = path; + const baseUrlMeta = document.querySelector('meta[name="base-url"]'); + if (!baseUrlMeta) { + throw new Error('Could not find expected base-url meta tag in document'); + } + + let basePath = baseUrlMeta.getAttribute('content') || ''; + if (basePath[basePath.length - 1] === '/') { + basePath = basePath.slice(0, basePath.length - 1); + } + + if (targetPath[0] === '/') { + targetPath = targetPath.slice(1); + } + + return `${basePath}/${targetPath}`; +} + +/** + * Get the current version of BookStack in use. + * Grabs this from the version query used on app assets. + */ +function getVersion(): string { + const styleLink = document.querySelector('link[href*="/dist/styles.css?version="]'); + if (!styleLink) { + throw new Error('Could not find expected style link in document for version use'); + } + + const href = (styleLink.getAttribute('href') || ''); + return href.split('?version=').pop() || ''; +} + +/** + * Perform a module import, Ensuring the import is fetched with the current + * app version as a cache-breaker. + */ +export function importVersioned(moduleName: string): Promise { + const importPath = window.baseUrl(`dist/${moduleName}.js?version=${getVersion()}`); + return import(importPath); +} \ No newline at end of file From 209fa04752905644166e441eccff832a7a9fab52 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 11 Oct 2024 21:55:51 +0100 Subject: [PATCH 03/72] TS: Converted dom and keyboard nav services --- dev/build/esbuild.js | 2 +- resources/js/components/add-remove-rows.js | 2 +- resources/js/components/ajax-delete-row.js | 2 +- resources/js/components/ajax-form.js | 2 +- resources/js/components/attachments.js | 2 +- resources/js/components/auto-suggest.js | 4 +- resources/js/components/book-sort.js | 2 +- resources/js/components/code-editor.js | 2 +- resources/js/components/confirm-dialog.js | 2 +- resources/js/components/dropdown.js | 4 +- resources/js/components/dropzone.js | 2 +- resources/js/components/entity-permissions.js | 2 +- resources/js/components/entity-search.js | 2 +- resources/js/components/entity-selector.js | 2 +- resources/js/components/event-emit-select.js | 2 +- resources/js/components/global-search.js | 4 +- resources/js/components/image-manager.js | 2 +- resources/js/components/optional-input.js | 2 +- resources/js/components/page-comment.js | 2 +- resources/js/components/page-comments.js | 2 +- resources/js/components/page-display.js | 2 +- resources/js/components/page-editor.js | 2 +- resources/js/components/pointer.js | 2 +- resources/js/components/popup.js | 2 +- resources/js/components/template-manager.js | 2 +- resources/js/components/user-select.js | 2 +- resources/js/services/{dom.js => dom.ts} | 85 ++++++++----------- ...d-navigation.js => keyboard-navigation.ts} | 42 ++++----- 28 files changed, 87 insertions(+), 98 deletions(-) rename resources/js/services/{dom.js => dom.ts} (63%) rename resources/js/services/{keyboard-navigation.js => keyboard-navigation.ts} (66%) diff --git a/dev/build/esbuild.js b/dev/build/esbuild.js index fea8c01e353..cd8bf279f28 100644 --- a/dev/build/esbuild.js +++ b/dev/build/esbuild.js @@ -10,7 +10,7 @@ const isProd = process.argv[2] === 'production'; // Gather our input files const entryPoints = { - app: path.join(__dirname, '../../resources/js/app.js'), + app: path.join(__dirname, '../../resources/js/app.ts'), code: path.join(__dirname, '../../resources/js/code/index.mjs'), 'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'), markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'), diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js index 488654279b4..e7de15ae5fa 100644 --- a/resources/js/components/add-remove-rows.js +++ b/resources/js/components/add-remove-rows.js @@ -1,4 +1,4 @@ -import {onChildEvent} from '../services/dom'; +import {onChildEvent} from '../services/dom.ts'; import {uniqueId} from '../services/util.ts'; import {Component} from './component'; diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js index aa2801f19e6..6ed3deedf4d 100644 --- a/resources/js/components/ajax-delete-row.js +++ b/resources/js/components/ajax-delete-row.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; export class AjaxDeleteRow extends Component { diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js index 583dde57244..de1a6db43a7 100644 --- a/resources/js/components/ajax-form.js +++ b/resources/js/components/ajax-form.js @@ -1,4 +1,4 @@ -import {onEnterPress, onSelect} from '../services/dom'; +import {onEnterPress, onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js index f45b25e36d0..2dc7313a880 100644 --- a/resources/js/components/attachments.js +++ b/resources/js/components/attachments.js @@ -1,4 +1,4 @@ -import {showLoading} from '../services/dom'; +import {showLoading} from '../services/dom.ts'; import {Component} from './component'; export class Attachments extends Component { diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js index 07711312f1d..0b828e71bd1 100644 --- a/resources/js/components/auto-suggest.js +++ b/resources/js/components/auto-suggest.js @@ -1,7 +1,7 @@ import {escapeHtml} from '../services/util.ts'; -import {onChildEvent} from '../services/dom'; +import {onChildEvent} from '../services/dom.ts'; import {Component} from './component'; -import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; +import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; const ajaxCache = {}; diff --git a/resources/js/components/book-sort.js b/resources/js/components/book-sort.js index 2ba7d5d36b0..48557141f6f 100644 --- a/resources/js/components/book-sort.js +++ b/resources/js/components/book-sort.js @@ -1,6 +1,6 @@ import Sortable, {MultiDrag} from 'sortablejs'; import {Component} from './component'; -import {htmlToDom} from '../services/dom'; +import {htmlToDom} from '../services/dom.ts'; // Auto sort control const sortOperations = { diff --git a/resources/js/components/code-editor.js b/resources/js/components/code-editor.js index 091c3483f4d..12937d47293 100644 --- a/resources/js/components/code-editor.js +++ b/resources/js/components/code-editor.js @@ -1,4 +1,4 @@ -import {onChildEvent, onEnterPress, onSelect} from '../services/dom'; +import {onChildEvent, onEnterPress, onSelect} from '../services/dom.ts'; import {Component} from './component'; export class CodeEditor extends Component { diff --git a/resources/js/components/confirm-dialog.js b/resources/js/components/confirm-dialog.js index 184618fccfa..00f3cfed201 100644 --- a/resources/js/components/confirm-dialog.js +++ b/resources/js/components/confirm-dialog.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index 4efd428acf7..5dd5dd93b01 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -1,5 +1,5 @@ -import {onSelect} from '../services/dom'; -import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; +import {onSelect} from '../services/dom.ts'; +import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index 920fe875f22..598e0d8d48b 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -2,7 +2,7 @@ import {Component} from './component'; import {Clipboard} from '../services/clipboard.ts'; import { elem, getLoading, onSelect, removeLoading, -} from '../services/dom'; +} from '../services/dom.ts'; export class Dropzone extends Component { diff --git a/resources/js/components/entity-permissions.js b/resources/js/components/entity-permissions.js index 7ab99a2a70b..b020c5d85ba 100644 --- a/resources/js/components/entity-permissions.js +++ b/resources/js/components/entity-permissions.js @@ -1,4 +1,4 @@ -import {htmlToDom} from '../services/dom'; +import {htmlToDom} from '../services/dom.ts'; import {Component} from './component'; export class EntityPermissions extends Component { diff --git a/resources/js/components/entity-search.js b/resources/js/components/entity-search.js index 7a50444708d..9d45133266d 100644 --- a/resources/js/components/entity-search.js +++ b/resources/js/components/entity-search.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; export class EntitySearch extends Component { diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js index 561370d7a34..7491119a137 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -1,4 +1,4 @@ -import {onChildEvent} from '../services/dom'; +import {onChildEvent} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/event-emit-select.js b/resources/js/components/event-emit-select.js index 2097c052886..f722a25e71b 100644 --- a/resources/js/components/event-emit-select.js +++ b/resources/js/components/event-emit-select.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/global-search.js b/resources/js/components/global-search.js index 44c0d02f9d4..2cdaf591ac4 100644 --- a/resources/js/components/global-search.js +++ b/resources/js/components/global-search.js @@ -1,6 +1,6 @@ -import {htmlToDom} from '../services/dom'; +import {htmlToDom} from '../services/dom.ts'; import {debounce} from '../services/util.ts'; -import {KeyboardNavigationHandler} from '../services/keyboard-navigation'; +import {KeyboardNavigationHandler} from '../services/keyboard-navigation.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index 47231477b68..c8108ab28c1 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -1,6 +1,6 @@ import { onChildEvent, onSelect, removeLoading, showLoading, -} from '../services/dom'; +} from '../services/dom.ts'; import {Component} from './component'; export class ImageManager extends Component { diff --git a/resources/js/components/optional-input.js b/resources/js/components/optional-input.js index 64cee12cd29..1b133047d00 100644 --- a/resources/js/components/optional-input.js +++ b/resources/js/components/optional-input.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; export class OptionalInput extends Component { diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js index fd8ad1f2e47..8c0a8b33e54 100644 --- a/resources/js/components/page-comment.js +++ b/resources/js/components/page-comment.js @@ -1,5 +1,5 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom'; +import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComment extends Component { diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 1d6abfe2044..3d7e1365f30 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -1,5 +1,5 @@ import {Component} from './component'; -import {getLoading, htmlToDom} from '../services/dom'; +import {getLoading, htmlToDom} from '../services/dom.ts'; import {buildForInput} from '../wysiwyg-tinymce/config'; export class PageComments extends Component { diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index ff9d68c7a5a..d3ac78a4ad1 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -1,4 +1,4 @@ -import * as DOM from '../services/dom'; +import * as DOM from '../services/dom.ts'; import {scrollAndHighlightElement} from '../services/util.ts'; import {Component} from './component'; diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index 9450444ca41..7ffceb0f904 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -1,4 +1,4 @@ -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {debounce} from '../services/util.ts'; import {Component} from './component'; import {utcTimeStampToLocalTime} from '../services/dates.ts'; diff --git a/resources/js/components/pointer.js b/resources/js/components/pointer.js index 607576cb9f9..292b923e551 100644 --- a/resources/js/components/pointer.js +++ b/resources/js/components/pointer.js @@ -1,4 +1,4 @@ -import * as DOM from '../services/dom'; +import * as DOM from '../services/dom.ts'; import {Component} from './component'; import {copyTextToClipboard} from '../services/clipboard.ts'; diff --git a/resources/js/components/popup.js b/resources/js/components/popup.js index edd42803733..6bd8f9c722b 100644 --- a/resources/js/components/popup.js +++ b/resources/js/components/popup.js @@ -1,5 +1,5 @@ import {fadeIn, fadeOut} from '../services/animations.ts'; -import {onSelect} from '../services/dom'; +import {onSelect} from '../services/dom.ts'; import {Component} from './component'; /** diff --git a/resources/js/components/template-manager.js b/resources/js/components/template-manager.js index 56ec876d48b..cf81990ab3f 100644 --- a/resources/js/components/template-manager.js +++ b/resources/js/components/template-manager.js @@ -1,4 +1,4 @@ -import * as DOM from '../services/dom'; +import * as DOM from '../services/dom.ts'; import {Component} from './component'; export class TemplateManager extends Component { diff --git a/resources/js/components/user-select.js b/resources/js/components/user-select.js index e6adc3c23c8..f9ec03ed366 100644 --- a/resources/js/components/user-select.js +++ b/resources/js/components/user-select.js @@ -1,4 +1,4 @@ -import {onChildEvent} from '../services/dom'; +import {onChildEvent} from '../services/dom.ts'; import {Component} from './component'; export class UserSelect extends Component { diff --git a/resources/js/services/dom.js b/resources/js/services/dom.ts similarity index 63% rename from resources/js/services/dom.js rename to resources/js/services/dom.ts index bcfd0b565da..c88827bac40 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.ts @@ -1,12 +1,15 @@ +/** + * Check if the given param is a HTMLElement + */ +export function isHTMLElement(el: any): el is HTMLElement { + return el instanceof HTMLElement; +} + /** * Create a new element with the given attrs and children. * Children can be a string for text nodes or other elements. - * @param {String} tagName - * @param {Object} attrs - * @param {Element[]|String[]}children - * @return {*} */ -export function elem(tagName, attrs = {}, children = []) { +export function elem(tagName: string, attrs: Record = {}, children: Element[]|string[] = []): HTMLElement { const el = document.createElement(tagName); for (const [key, val] of Object.entries(attrs)) { @@ -30,10 +33,8 @@ export function elem(tagName, attrs = {}, children = []) { /** * Run the given callback against each element that matches the given selector. - * @param {String} selector - * @param {Function} callback */ -export function forEach(selector, callback) { +export function forEach(selector: string, callback: (el: Element) => any) { const elements = document.querySelectorAll(selector); for (const element of elements) { callback(element); @@ -42,11 +43,8 @@ export function forEach(selector, callback) { /** * Helper to listen to multiple DOM events - * @param {Element} listenerElement - * @param {Array} events - * @param {Function} callback */ -export function onEvents(listenerElement, events, callback) { +export function onEvents(listenerElement: Element, events: string[], callback: (e: Event) => any): void { for (const eventName of events) { listenerElement.addEventListener(eventName, callback); } @@ -55,10 +53,8 @@ export function onEvents(listenerElement, events, callback) { /** * Helper to run an action when an element is selected. * A "select" is made to be accessible, So can be a click, space-press or enter-press. - * @param {HTMLElement|Array} elements - * @param {function} callback */ -export function onSelect(elements, callback) { +export function onSelect(elements: HTMLElement|HTMLElement[], callback: (e: Event) => any): void { if (!Array.isArray(elements)) { elements = [elements]; } @@ -76,16 +72,13 @@ export function onSelect(elements, callback) { /** * Listen to key press on the given element(s). - * @param {String} key - * @param {HTMLElement|Array} elements - * @param {function} callback */ -function onKeyPress(key, elements, callback) { +function onKeyPress(key: string, elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void { if (!Array.isArray(elements)) { elements = [elements]; } - const listener = event => { + const listener = (event: KeyboardEvent) => { if (event.key === key) { callback(event); } @@ -96,19 +89,15 @@ function onKeyPress(key, elements, callback) { /** * Listen to enter press on the given element(s). - * @param {HTMLElement|Array} elements - * @param {function} callback */ -export function onEnterPress(elements, callback) { +export function onEnterPress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void { onKeyPress('Enter', elements, callback); } /** * Listen to escape press on the given element(s). - * @param {HTMLElement|Array} elements - * @param {function} callback */ -export function onEscapePress(elements, callback) { +export function onEscapePress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void { onKeyPress('Escape', elements, callback); } @@ -116,14 +105,15 @@ export function onEscapePress(elements, callback) { * Set a listener on an element for an event emitted by a child * matching the given childSelector param. * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback) - * @param {Element} listenerElement - * @param {String} childSelector - * @param {String} eventName - * @param {Function} callback */ -export function onChildEvent(listenerElement, childSelector, eventName, callback) { - listenerElement.addEventListener(eventName, event => { - const matchingChild = event.target.closest(childSelector); +export function onChildEvent( + listenerElement: HTMLElement, + childSelector: string, + eventName: string, + callback: (this: HTMLElement, e: Event, child: HTMLElement) => any +): void { + listenerElement.addEventListener(eventName, (event: Event) => { + const matchingChild = (event.target as HTMLElement|null)?.closest(childSelector) as HTMLElement; if (matchingChild) { callback.call(matchingChild, event, matchingChild); } @@ -132,16 +122,13 @@ export function onChildEvent(listenerElement, childSelector, eventName, callback /** * Look for elements that match the given selector and contain the given text. - * Is case insensitive and returns the first result or null if nothing is found. - * @param {String} selector - * @param {String} text - * @returns {Element} + * Is case-insensitive and returns the first result or null if nothing is found. */ -export function findText(selector, text) { +export function findText(selector: string, text: string): Element|null { const elements = document.querySelectorAll(selector); text = text.toLowerCase(); for (const element of elements) { - if (element.textContent.toLowerCase().includes(text)) { + if ((element.textContent || '').toLowerCase().includes(text) && isHTMLElement(element)) { return element; } } @@ -151,17 +138,15 @@ export function findText(selector, text) { /** * Show a loading indicator in the given element. * This will effectively clear the element. - * @param {Element} element */ -export function showLoading(element) { +export function showLoading(element: HTMLElement): void { element.innerHTML = '
'; } /** * Get a loading element indicator element. - * @returns {Element} */ -export function getLoading() { +export function getLoading(): HTMLElement { const wrap = document.createElement('div'); wrap.classList.add('loading-container'); wrap.innerHTML = '
'; @@ -170,9 +155,8 @@ export function getLoading() { /** * Remove any loading indicators within the given element. - * @param {Element} element */ -export function removeLoading(element) { +export function removeLoading(element: HTMLElement): void { const loadingEls = element.querySelectorAll('.loading-container'); for (const el of loadingEls) { el.remove(); @@ -182,12 +166,15 @@ export function removeLoading(element) { /** * Convert the given html data into a live DOM element. * Initiates any components defined in the data. - * @param {String} html - * @returns {Element} */ -export function htmlToDom(html) { +export function htmlToDom(html: string): HTMLElement { const wrap = document.createElement('div'); wrap.innerHTML = html; window.$components.init(wrap); - return wrap.children[0]; + const firstChild = wrap.children[0]; + if (!isHTMLElement(firstChild)) { + throw new Error('Could not find child HTMLElement when creating DOM element from HTML'); + } + + return firstChild; } diff --git a/resources/js/services/keyboard-navigation.js b/resources/js/services/keyboard-navigation.ts similarity index 66% rename from resources/js/services/keyboard-navigation.js rename to resources/js/services/keyboard-navigation.ts index 34111bb2d37..13fbdfecc9d 100644 --- a/resources/js/services/keyboard-navigation.js +++ b/resources/js/services/keyboard-navigation.ts @@ -1,14 +1,17 @@ +import {isHTMLElement} from "./dom"; + +type OptionalKeyEventHandler = ((e: KeyboardEvent) => any)|null; + /** * Handle common keyboard navigation events within a given container. */ export class KeyboardNavigationHandler { - /** - * @param {Element} container - * @param {Function|null} onEscape - * @param {Function|null} onEnter - */ - constructor(container, onEscape = null, onEnter = null) { + protected containers: HTMLElement[]; + protected onEscape: OptionalKeyEventHandler; + protected onEnter: OptionalKeyEventHandler; + + constructor(container: HTMLElement, onEscape: OptionalKeyEventHandler = null, onEnter: OptionalKeyEventHandler = null) { this.containers = [container]; this.onEscape = onEscape; this.onEnter = onEnter; @@ -18,9 +21,8 @@ export class KeyboardNavigationHandler { /** * Also share the keyboard event handling to the given element. * Only elements within the original container are considered focusable though. - * @param {Element} element */ - shareHandlingToEl(element) { + shareHandlingToEl(element: HTMLElement) { this.containers.push(element); element.addEventListener('keydown', this.#keydownHandler.bind(this)); } @@ -30,7 +32,8 @@ export class KeyboardNavigationHandler { */ focusNext() { const focusable = this.#getFocusable(); - const currentIndex = focusable.indexOf(document.activeElement); + const activeEl = document.activeElement; + const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1; let newIndex = currentIndex + 1; if (newIndex >= focusable.length) { newIndex = 0; @@ -44,7 +47,8 @@ export class KeyboardNavigationHandler { */ focusPrevious() { const focusable = this.#getFocusable(); - const currentIndex = focusable.indexOf(document.activeElement); + const activeEl = document.activeElement; + const currentIndex = isHTMLElement(activeEl) ? focusable.indexOf(activeEl) : -1; let newIndex = currentIndex - 1; if (newIndex < 0) { newIndex = focusable.length - 1; @@ -53,12 +57,9 @@ export class KeyboardNavigationHandler { focusable[newIndex].focus(); } - /** - * @param {KeyboardEvent} event - */ - #keydownHandler(event) { + #keydownHandler(event: KeyboardEvent) { // Ignore certain key events in inputs to allow text editing. - if (event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { + if (isHTMLElement(event.target) && event.target.matches('input') && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) { return; } @@ -71,7 +72,7 @@ export class KeyboardNavigationHandler { } else if (event.key === 'Escape') { if (this.onEscape) { this.onEscape(event); - } else if (document.activeElement) { + } else if (isHTMLElement(document.activeElement)) { document.activeElement.blur(); } } else if (event.key === 'Enter' && this.onEnter) { @@ -81,14 +82,15 @@ export class KeyboardNavigationHandler { /** * Get an array of focusable elements within the current containers. - * @returns {Element[]} */ - #getFocusable() { - const focusable = []; + #getFocusable(): HTMLElement[] { + const focusable: HTMLElement[] = []; const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"],[disabled]),input:not([type=hidden])'; for (const container of this.containers) { - focusable.push(...container.querySelectorAll(selector)); + const toAdd = [...container.querySelectorAll(selector)].filter(e => isHTMLElement(e)); + focusable.push(...toAdd); } + return focusable; } From e088d09e4705a4348444aaa8ec1b49a07128e063 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 13 Oct 2024 14:18:23 +0100 Subject: [PATCH 04/72] ZIP Export: Started defining format --- dev/docs/portable-zip-file-format.md | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 dev/docs/portable-zip-file-format.md diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md new file mode 100644 index 00000000000..260735c58cf --- /dev/null +++ b/dev/docs/portable-zip-file-format.md @@ -0,0 +1,82 @@ +# Portable ZIP File Format + +BookStack provides exports in a "Portable ZIP" which allows the portable transfer, storage, import & export of BookStack content. +This document details the format used, and is intended for our own internal development use in addition to detailing the format for potential external use-cases (readers, apps, import for other platforms etc...). + +**Note:** This is not a BookStack backup format! This format misses much of the data that would be needed to re-create/restore a BookStack instance. There are existing better alternative options for this use-case. + +## Stability + +Following the goals & ideals of BookStack, stability is very important. We aim for this defined format to be stable and forwards compatible, to prevent breakages in use-case due to changes. Here are the general rules we follow in regard to stability & changes: + +- New features & properties may be added with any release. +- Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties. +- Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes. + +The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage. For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you. + +## Format Outline + +The format is intended to be very simple, readable and based on open standards that could be easily read/handled in most common programming languages. +The below outlines the structure of the format: + +- **ZIP archive container** + - **data.json** - Application data. + - **files/** - Directory containing referenced files. + - *...file.ext* + +## References + +TODO - Define how we reference across content: +TODO - References to files from data.json +TODO - References from in-content to file URLs +TODO - References from in-content to in-export content (page cross links within same export). + +## Application Data - `data.json` + +The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows: + +- `instance` - [Instance](#instance) Object, optional, details of the export source instance. +- `exported_at` - String, optional, full ISO 8601 datetime of when the export was created. +- `book` - [Book](#book) Object, optional, book export data. +- `chapter` - [Chapter](#chapter) Object, optional, chapter export data. +- `page` - [Page](#page) Object, optional, page export data. + +Either `book`, `chapter` or `page` will exist depending on export type. You'd want to check for each to check what kind of export this is, and if it's an export you can handle. It's possible that other options are added in the future (`books` for a range of books for example) so it'd be wise to specifically check for properties that can be handled, otherwise error to indicate lack of support. + +## Data Objects + +The below details the objects & their properties used in Application Data. + +#### Instance + +These details are mainly informational regarding the exporting BookStack instance from where an export was created from. + +- `version` - String, required, BookStack version of the export source instance. +- `id_ciphertext` - String, required, identifier for the BookStack instance. + +The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This is used as a simple & rough way for a BookStack instance to be able to identify if they were the source (by attempting to decrypt the ciphertext). + +#### Book + +TODO + +#### Chapter + +TODO + +#### Page + +TODO + +#### Image + +TODO + +#### Attachment + +TODO + +#### Tag + +TODO \ No newline at end of file From 1930af91cea9ab830562933f738b1c9d151b0640 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 13 Oct 2024 22:56:22 +0100 Subject: [PATCH 05/72] ZIP Export: Started types in format doc --- dev/docs/portable-zip-file-format.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 260735c58cf..c4737309f48 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -59,11 +59,22 @@ The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This i #### Book -TODO +- `id` - Number, optional, original ID for the book from exported system. +- `name` - String, required, name/title of the book. +- `description_html` - String, optional, HTML description content. +- `chapters` - [Chapter](#chapter) array, optional, chapters within this book. +- `pages` - [Page](#page) array, optional, direct child pages for this book. +- `tags` - [Tag](#tag) array, optional, tags assigned to this book. + +The `pages` are not all pages within the book, just those that are direct children (not in a chapter). To build an ordered mixed chapter/page list for the book, as what you'd see in BookStack, you'd need to combine `chapters` and `pages` together and sort by their `priority` value (low to high). #### Chapter -TODO +- `id` - Number, optional, original ID for the chapter from exported system. +- `name` - String, required, name/title of the chapter. +- `description_html` - String, optional, HTML description content. +- `pages` - [Page](#page) array, optional, pages within this chapter. +- `tags` - [Tag](#tag) array, optional, tags assigned to this chapter. #### Page @@ -79,4 +90,6 @@ TODO #### Tag -TODO \ No newline at end of file +- `name` - String, required, name of the tag. +- `value` - String, optional, value of the tag (can be empty). +- `order` - Number, optional, integer order for the tags (shown low to high). \ No newline at end of file From 42bd07d73325ed468bae2658ba130314f6fe4a21 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 15 Oct 2024 13:57:16 +0100 Subject: [PATCH 06/72] ZIP Export: Continued expanding format doc types --- dev/docs/portable-zip-file-format.md | 57 ++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index c4737309f48..dc21bf8e58e 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -21,18 +21,38 @@ The format is intended to be very simple, readable and based on open standards t The below outlines the structure of the format: - **ZIP archive container** - - **data.json** - Application data. + - **data.json** - Export data. - **files/** - Directory containing referenced files. - - *...file.ext* + - *file-a* + - *file-b* + - *...* ## References +Some properties in the export data JSON are indicated as `String reference`, and these are direct references to a file name within the `files/` directory of the ZIP. For example, the below book cover is directly referencing a `files/4a5m4a.jpg` within the ZIP which would be expected to exist. + +```json +{ + "book": { + "cover": "4a5m4a.jpg" + } +} +``` + +TODO - Jotting out idea below. +Would need to validate image/attachment paths against image/attachments listed across all pages in export. +Probably good to ensure filenames are ascii-alpha-num. +`[[bsexport:image:an-image-path.png]]` +`[[bsexport:attachment:an-image-path.png]]` +`[[bsexport:page:1]]` +`[[bsexport:chapter:2]]` +`[[bsexport:book:3]]` + TODO - Define how we reference across content: -TODO - References to files from data.json TODO - References from in-content to file URLs TODO - References from in-content to in-export content (page cross links within same export). -## Application Data - `data.json` +## Export Data - `data.json` The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows: @@ -62,6 +82,7 @@ The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This i - `id` - Number, optional, original ID for the book from exported system. - `name` - String, required, name/title of the book. - `description_html` - String, optional, HTML description content. +- `cover` - String reference, options, reference to book cover image. - `chapters` - [Chapter](#chapter) array, optional, chapters within this book. - `pages` - [Page](#page) array, optional, direct child pages for this book. - `tags` - [Tag](#tag) array, optional, tags assigned to this book. @@ -73,23 +94,43 @@ The `pages` are not all pages within the book, just those that are direct childr - `id` - Number, optional, original ID for the chapter from exported system. - `name` - String, required, name/title of the chapter. - `description_html` - String, optional, HTML description content. +- `priority` - Number, optional, integer order for when shown within a book (shown low to high). - `pages` - [Page](#page) array, optional, pages within this chapter. - `tags` - [Tag](#tag) array, optional, tags assigned to this chapter. #### Page -TODO +- `id` - Number, optional, original ID for the page from exported system. +- `name` - String, required, name/title of the page. +- `html` - String, optional, page HTML content. +- `markdown` - String, optional, user markdown content for this page. +- `priority` - Number, optional, integer order for when shown within a book (shown low to high). +- `attachments` - [Attachment](#attachment) array, optional, attachments uploaded to this page. +- `images` - [Image](#image) array, optional, images used in this page. +- `tags` - [Tag](#tag) array, optional, tags assigned to this page. + +To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. + +The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor and display content. #### Image -TODO +- `name` - String, required, name of image. +- `file` - String reference, required, reference to image file. + +File must be an image type accepted by BookStack (png, jpg, gif, webp) #### Attachment -TODO +- `name` - String, required, name of attachment. +- `link` - String, semi-optional, URL of attachment. +- `file` - String reference, semi-optional, reference to attachment file. +- `order` - Number, optional, integer order of the attachments (shown low to high). + +Either `link` or `file` must be present, as that will determine the type of attachment. #### Tag - `name` - String, required, name of the tag. - `value` - String, optional, value of the tag (can be empty). -- `order` - Number, optional, integer order for the tags (shown low to high). \ No newline at end of file +- `order` - Number, optional, integer order of the tags (shown low to high). \ No newline at end of file From 42b9700673e7b2e5a04c9f888a05d98261ed36e3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 15 Oct 2024 16:14:11 +0100 Subject: [PATCH 07/72] ZIP Exports: Finished up format doc, move files, started builder Moved all existing export related app files into their new own dir. --- app/Exceptions/ZipExportException.php | 7 +++ .../Controllers/BookExportApiController.php | 4 +- .../Controllers/BookExportController.php | 4 +- .../ChapterExportApiController.php | 4 +- .../Controllers/ChapterExportController.php | 4 +- .../Controllers/PageExportApiController.php | 4 +- .../Controllers/PageExportController.php | 4 +- .../Tools => Exports}/ExportFormatter.php | 4 +- .../Tools => Exports}/PdfGenerator.php | 4 +- app/Exports/ZipExportBuilder.php | 48 +++++++++++++++++++ composer.json | 1 + dev/docs/portable-zip-file-format.md | 30 +++++++----- routes/api.php | 26 +++++----- routes/web.php | 27 ++++++----- tests/Entity/ExportTest.php | 2 +- 15 files changed, 119 insertions(+), 54 deletions(-) create mode 100644 app/Exceptions/ZipExportException.php rename app/{Entities => Exports}/Controllers/BookExportApiController.php (95%) rename app/{Entities => Exports}/Controllers/BookExportController.php (95%) rename app/{Entities => Exports}/Controllers/ChapterExportApiController.php (95%) rename app/{Entities => Exports}/Controllers/ChapterExportController.php (96%) rename app/{Entities => Exports}/Controllers/PageExportApiController.php (95%) rename app/{Entities => Exports}/Controllers/PageExportController.php (96%) rename app/{Entities/Tools => Exports}/ExportFormatter.php (98%) rename app/{Entities/Tools => Exports}/PdfGenerator.php (99%) create mode 100644 app/Exports/ZipExportBuilder.php diff --git a/app/Exceptions/ZipExportException.php b/app/Exceptions/ZipExportException.php new file mode 100644 index 00000000000..b2c811e0b33 --- /dev/null +++ b/app/Exceptions/ZipExportException.php @@ -0,0 +1,7 @@ +data['page'] = [ + 'id' => $page->id, + ]; + + return $this->build(); + } + + /** + * @throws ZipExportException + */ + protected function build(): string + { + $this->data['exported_at'] = date(DATE_ATOM); + $this->data['instance'] = [ + 'version' => trim(file_get_contents(base_path('version'))), + 'id_ciphertext' => encrypt('bookstack'), + ]; + + $zipFile = tempnam(sys_get_temp_dir(), 'bszip-'); + $zip = new ZipArchive(); + $opened = $zip->open($zipFile, ZipArchive::CREATE); + if ($opened !== true) { + throw new ZipExportException('Failed to create zip file for export.'); + } + + $zip->addFromString('data.json', json_encode($this->data)); + $zip->addEmptyDir('files'); + + return $zipFile; + } +} diff --git a/composer.json b/composer.json index 5c54774f1e2..3680a2c6aa2 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-json": "*", "ext-mbstring": "*", "ext-xml": "*", + "ext-zip": "*", "bacon/bacon-qr-code": "^3.0", "doctrine/dbal": "^3.5", "dompdf/dompdf": "^3.0", diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index dc21bf8e58e..d5635bd398d 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -39,18 +39,24 @@ Some properties in the export data JSON are indicated as `String reference`, and } ``` -TODO - Jotting out idea below. -Would need to validate image/attachment paths against image/attachments listed across all pages in export. -Probably good to ensure filenames are ascii-alpha-num. -`[[bsexport:image:an-image-path.png]]` -`[[bsexport:attachment:an-image-path.png]]` -`[[bsexport:page:1]]` -`[[bsexport:chapter:2]]` -`[[bsexport:book:3]]` - -TODO - Define how we reference across content: -TODO - References from in-content to file URLs -TODO - References from in-content to in-export content (page cross links within same export). +Within HTML and markdown content, you may require references across to other items within the export content. +This can be done using the following format: + +``` +[[bsexport::]] +``` + +Images and attachments are referenced via their file name within the `files/` directory. +Otherwise, other content types are referenced by `id`. +Here's an example of each type of such reference that could be used: + +``` +[[bsexport:image:an-image-path.png]] +[[bsexport:attachment:an-image-path.png]] +[[bsexport:page:40]] +[[bsexport:chapter:2]] +[[bsexport:book:8]] +``` ## Export Data - `data.json` diff --git a/routes/api.php b/routes/api.php index c0919d3247b..71036485597 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,7 @@ use BookStack\Activity\Controllers\AuditLogApiController; use BookStack\Api\ApiDocsController; use BookStack\Entities\Controllers as EntityControllers; +use BookStack\Exports\Controllers as ExportControllers; use BookStack\Permissions\ContentPermissionApiController; use BookStack\Search\SearchApiController; use BookStack\Uploads\Controllers\AttachmentApiController; @@ -31,21 +32,20 @@ Route::put('books/{id}', [EntityControllers\BookApiController::class, 'update']); Route::delete('books/{id}', [EntityControllers\BookApiController::class, 'delete']); -Route::get('books/{id}/export/html', [EntityControllers\BookExportApiController::class, 'exportHtml']); -Route::get('books/{id}/export/pdf', [EntityControllers\BookExportApiController::class, 'exportPdf']); -Route::get('books/{id}/export/plaintext', [EntityControllers\BookExportApiController::class, 'exportPlainText']); -Route::get('books/{id}/export/markdown', [EntityControllers\BookExportApiController::class, 'exportMarkdown']); +Route::get('books/{id}/export/html', [ExportControllers\BookExportApiController::class, 'exportHtml']); +Route::get('books/{id}/export/pdf', [ExportControllers\BookExportApiController::class, 'exportPdf']); +Route::get('books/{id}/export/plaintext', [ExportControllers\BookExportApiController::class, 'exportPlainText']); +Route::get('books/{id}/export/markdown', [ExportControllers\BookExportApiController::class, 'exportMarkdown']); Route::get('chapters', [EntityControllers\ChapterApiController::class, 'list']); Route::post('chapters', [EntityControllers\ChapterApiController::class, 'create']); Route::get('chapters/{id}', [EntityControllers\ChapterApiController::class, 'read']); Route::put('chapters/{id}', [EntityControllers\ChapterApiController::class, 'update']); Route::delete('chapters/{id}', [EntityControllers\ChapterApiController::class, 'delete']); - -Route::get('chapters/{id}/export/html', [EntityControllers\ChapterExportApiController::class, 'exportHtml']); -Route::get('chapters/{id}/export/pdf', [EntityControllers\ChapterExportApiController::class, 'exportPdf']); -Route::get('chapters/{id}/export/plaintext', [EntityControllers\ChapterExportApiController::class, 'exportPlainText']); -Route::get('chapters/{id}/export/markdown', [EntityControllers\ChapterExportApiController::class, 'exportMarkdown']); +Route::get('chapters/{id}/export/html', [ExportControllers\ChapterExportApiController::class, 'exportHtml']); +Route::get('chapters/{id}/export/pdf', [ExportControllers\ChapterExportApiController::class, 'exportPdf']); +Route::get('chapters/{id}/export/plaintext', [ExportControllers\ChapterExportApiController::class, 'exportPlainText']); +Route::get('chapters/{id}/export/markdown', [ExportControllers\ChapterExportApiController::class, 'exportMarkdown']); Route::get('pages', [EntityControllers\PageApiController::class, 'list']); Route::post('pages', [EntityControllers\PageApiController::class, 'create']); @@ -53,10 +53,10 @@ Route::put('pages/{id}', [EntityControllers\PageApiController::class, 'update']); Route::delete('pages/{id}', [EntityControllers\PageApiController::class, 'delete']); -Route::get('pages/{id}/export/html', [EntityControllers\PageExportApiController::class, 'exportHtml']); -Route::get('pages/{id}/export/pdf', [EntityControllers\PageExportApiController::class, 'exportPdf']); -Route::get('pages/{id}/export/plaintext', [EntityControllers\PageExportApiController::class, 'exportPlainText']); -Route::get('pages/{id}/export/markdown', [EntityControllers\PageExportApiController::class, 'exportMarkdown']); +Route::get('pages/{id}/export/html', [ExportControllers\PageExportApiController::class, 'exportHtml']); +Route::get('pages/{id}/export/pdf', [ExportControllers\PageExportApiController::class, 'exportPdf']); +Route::get('pages/{id}/export/plaintext', [ExportControllers\PageExportApiController::class, 'exportPlainText']); +Route::get('pages/{id}/export/markdown', [ExportControllers\PageExportApiController::class, 'exportMarkdown']); Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); diff --git a/routes/web.php b/routes/web.php index 81b938f32ec..5220684c0b0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use BookStack\App\HomeController; use BookStack\App\MetaController; use BookStack\Entities\Controllers as EntityControllers; +use BookStack\Exports\Controllers as ExportControllers; use BookStack\Http\Middleware\VerifyCsrfToken; use BookStack\Permissions\PermissionsController; use BookStack\References\ReferenceController; @@ -74,11 +75,11 @@ Route::get('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'show']); Route::put('/books/{bookSlug}/sort', [EntityControllers\BookSortController::class, 'update']); Route::get('/books/{slug}/references', [ReferenceController::class, 'book']); - Route::get('/books/{bookSlug}/export/html', [EntityControllers\BookExportController::class, 'html']); - Route::get('/books/{bookSlug}/export/pdf', [EntityControllers\BookExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/export/markdown', [EntityControllers\BookExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/export/zip', [EntityControllers\BookExportController::class, 'zip']); - Route::get('/books/{bookSlug}/export/plaintext', [EntityControllers\BookExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/export/html', [ExportControllers\BookExportController::class, 'html']); + Route::get('/books/{bookSlug}/export/pdf', [ExportControllers\BookExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/export/markdown', [ExportControllers\BookExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/export/zip', [ExportControllers\BookExportController::class, 'zip']); + Route::get('/books/{bookSlug}/export/plaintext', [ExportControllers\BookExportController::class, 'plainText']); // Pages Route::get('/books/{bookSlug}/create-page', [EntityControllers\PageController::class, 'create']); @@ -86,10 +87,10 @@ Route::get('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'editDraft']); Route::post('/books/{bookSlug}/draft/{pageId}', [EntityControllers\PageController::class, 'store']); Route::get('/books/{bookSlug}/page/{pageSlug}', [EntityControllers\PageController::class, 'show']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [EntityControllers\PageExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [EntityControllers\PageExportController::class, 'html']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [EntityControllers\PageExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [EntityControllers\PageExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/pdf', [ExportControllers\PageExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']); Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']); Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']); Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']); @@ -126,10 +127,10 @@ Route::get('/books/{bookSlug}/chapter/{chapterSlug}/edit', [EntityControllers\ChapterController::class, 'edit']); Route::post('/books/{bookSlug}/chapter/{chapterSlug}/convert-to-book', [EntityControllers\ChapterController::class, 'convertToBook']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'showForChapter']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [EntityControllers\ChapterExportController::class, 'pdf']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [EntityControllers\ChapterExportController::class, 'html']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [EntityControllers\ChapterExportController::class, 'markdown']); - Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [EntityControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/pdf', [ExportControllers\ChapterExportController::class, 'pdf']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']); diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 7aafa3b7927..11cfddb206e 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -5,8 +5,8 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; -use BookStack\Entities\Tools\PdfGenerator; use BookStack\Exceptions\PdfExportException; +use BookStack\Exports\PdfGenerator; use Illuminate\Support\Facades\Storage; use Tests\TestCase; From bf0262d7d178b494e256ff5164a8d75195cdc231 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 19 Oct 2024 13:59:42 +0100 Subject: [PATCH 08/72] Testing: Split export tests into multiple files --- tests/Entity/ExportTest.php | 569 --------------------------- tests/Exports/ExportUiTest.php | 33 ++ tests/Exports/HtmlExportTest.php | 253 ++++++++++++ tests/Exports/MarkdownExportTest.php | 85 ++++ tests/Exports/PdfExportTest.php | 146 +++++++ tests/Exports/TextExportTest.php | 88 +++++ 6 files changed, 605 insertions(+), 569 deletions(-) delete mode 100644 tests/Entity/ExportTest.php create mode 100644 tests/Exports/ExportUiTest.php create mode 100644 tests/Exports/HtmlExportTest.php create mode 100644 tests/Exports/MarkdownExportTest.php create mode 100644 tests/Exports/PdfExportTest.php create mode 100644 tests/Exports/TextExportTest.php diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php deleted file mode 100644 index 11cfddb206e..00000000000 --- a/tests/Entity/ExportTest.php +++ /dev/null @@ -1,569 +0,0 @@ -entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); - } - - public function test_page_pdf_export() - { - $page = $this->entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); - } - - public function test_page_html_export() - { - $page = $this->entities->page(); - $this->asEditor(); - - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); - } - - public function test_book_text_export() - { - $book = $this->entities->bookHasChaptersAndPages(); - $directPage = $book->directPages()->first(); - $chapter = $book->chapters()->first(); - $chapterPage = $chapter->pages()->first(); - $this->entities->updatePage($directPage, ['html' => '

My awesome page

']); - $this->entities->updatePage($chapterPage, ['html' => '

My little nested page

']); - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertSee($chapterPage->name); - $resp->assertSee($chapter->name); - $resp->assertSee($directPage->name); - $resp->assertSee('My awesome page'); - $resp->assertSee('My little nested page'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); - } - - public function test_book_text_export_format() - { - $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); - $this->entities->updatePage($entities['page'], ['html' => '

My great page

Full of great stuff

', 'name' => 'My wonderful page!']); - $entities['chapter']->name = 'Export chapter'; - $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; - $entities['book']->name = 'Export Book'; - $entities['book']->description = "This is a book with stuff to export"; - $entities['chapter']->save(); - $entities['book']->save(); - - $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); - - $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; - $resp->assertSee($expected); - } - - public function test_book_pdf_export() - { - $page = $this->entities->page(); - $book = $page->book; - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); - } - - public function test_book_html_export() - { - $page = $this->entities->page(); - $book = $page->book; - $this->asEditor(); - - $resp = $this->get($book->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($book->name); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); - } - - public function test_book_html_export_shows_html_descriptions() - { - $book = $this->entities->bookHasChaptersAndPages(); - $chapter = $book->chapters()->first(); - $book->description_html = '

A description with HTML within!

'; - $chapter->description_html = '

A chapter description with HTML within!

'; - $book->save(); - $chapter->save(); - - $resp = $this->asEditor()->get($book->getUrl('/export/html')); - $resp->assertSee($book->description_html, false); - $resp->assertSee($chapter->description_html, false); - } - - public function test_chapter_text_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages[0]; - $this->entities->updatePage($page, ['html' => '

This is content within the page!

']); - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/plaintext')); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertSee($page->name); - $resp->assertSee('This is content within the page!'); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); - } - - public function test_chapter_text_export_format() - { - $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); - $this->entities->updatePage($entities['page'], ['html' => '

My great page

Full of great stuff

', 'name' => 'My wonderful page!']); - $entities['chapter']->name = 'Export chapter'; - $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; - $entities['chapter']->save(); - - $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); - - $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; - $resp->assertSee($expected); - } - - public function test_chapter_pdf_export() - { - $chapter = $this->entities->chapter(); - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/pdf')); - $resp->assertStatus(200); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); - } - - public function test_chapter_html_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages[0]; - $this->asEditor(); - - $resp = $this->get($chapter->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($chapter->name); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); - } - - public function test_chapter_html_export_shows_html_descriptions() - { - $chapter = $this->entities->chapter(); - $chapter->description_html = '

A description with HTML within!

'; - $chapter->save(); - - $resp = $this->asEditor()->get($chapter->getUrl('/export/html')); - $resp->assertSee($chapter->description_html, false); - } - - public function test_page_html_export_contains_custom_head_if_set() - { - $page = $this->entities->page(); - - $customHeadContent = ''; - $this->setSettings(['app-custom-head' => $customHeadContent]); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($customHeadContent, false); - } - - public function test_page_html_export_does_not_break_with_only_comments_in_custom_head() - { - $page = $this->entities->page(); - - $customHeadContent = ''; - $this->setSettings(['app-custom-head' => $customHeadContent]); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertSee($customHeadContent, false); - } - - public function test_page_html_export_use_absolute_dates() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); - $resp->assertDontSee($page->created_at->diffForHumans()); - $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); - $resp->assertDontSee($page->updated_at->diffForHumans()); - } - - public function test_page_export_does_not_include_user_or_revision_links() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee($page->getUrl('/revisions')); - $resp->assertDontSee($page->createdBy->getProfileUrl()); - $resp->assertSee($page->createdBy->name); - } - - public function test_page_export_sets_right_data_type_for_svg_embeds() - { - $page = $this->entities->page(); - Storage::disk('local')->makeDirectory('uploads/images/gallery'); - Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); - $page->html = ''; - $page->save(); - - $this->asEditor(); - $resp = $this->get($page->getUrl('/export/html')); - Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); - - $resp->assertStatus(200); - $resp->assertSee(''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); - Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); - - $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); - } - - public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() - { - $page = $this->entities->page(); - $page->html = '' - . '' - . ''; - $storageDisk = Storage::disk('local'); - $storageDisk->makeDirectory('uploads/images/gallery'); - $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); - $storageDisk->put('uploads/svg_test.svg', 'bad'); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - - $storageDisk->delete('uploads/images/gallery/svg_test.svg'); - $storageDisk->delete('uploads/svg_test.svg'); - - $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); - $resp->assertSee('http://localhost/uploads/svg_test.svg'); - $resp->assertSee('src="/uploads/svg_test.svg"', false); - } - - public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() - { - $contents = file_get_contents(public_path('.htaccess')); - config()->set('filesystems.images', 'local'); - - $page = $this->entities->page(); - $page->html = ''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee(base64_encode($contents)); - } - - public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure() - { - $testFilePath = storage_path('logs/test.txt'); - config()->set('filesystems.images', 'local_secure'); - file_put_contents($testFilePath, 'I am a cat'); - - $page = $this->entities->page(); - $page->html = ''; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertDontSee(base64_encode('I am a cat')); - unlink($testFilePath); - } - - public function test_exports_removes_scripts_from_custom_head() - { - $entities = [ - Page::query()->first(), Chapter::query()->first(), Book::query()->first(), - ]; - setting()->put('app-custom-head', ''); - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $resp->assertDontSee('window.donkey'); - $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); - } - } - - public function test_page_export_with_deleted_creator_and_updater() - { - $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); - $page = $this->entities->page(); - $page->created_by = $user->id; - $page->updated_by = $user->id; - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $resp->assertSee('ExportWizardTheFifth'); - - $user->delete(); - $resp = $this->get($page->getUrl('/export/html')); - $resp->assertStatus(200); - $resp->assertDontSee('ExportWizardTheFifth'); - } - - public function test_page_pdf_export_converts_iframes_to_links() - { - $page = Page::query()->first()->forceFill([ - 'html' => '', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringNotContainsString('iframe>', $pdfHtml); - $this->assertStringContainsString('

https://www.youtube.com/embed/ShqUjt33uOs

', $pdfHtml); - } - - public function test_page_pdf_export_opens_details_blocks() - { - $page = $this->entities->page()->forceFill([ - 'html' => '
Hello

Content!

', - ]); - $page->save(); - - $pdfHtml = ''; - $mockPdfGenerator = $this->mock(PdfGenerator::class); - $mockPdfGenerator->shouldReceive('fromHtml') - ->with(\Mockery::capture($pdfHtml)) - ->andReturn(''); - $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); - - $this->asEditor()->get($page->getUrl('/export/pdf')); - $this->assertStringContainsString('
entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertStatus(200); - $resp->assertSee($page->name); - $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); - } - - public function test_page_markdown_export_uses_existing_markdown_if_apparent() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '# A header', - 'html' => '

Dogcat

', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee('A header'); - $resp->assertDontSee('Dogcat'); - } - - public function test_page_markdown_export_converts_html_where_no_markdown() - { - $page = $this->entities->page()->forceFill([ - 'markdown' => '', - 'html' => '

Dogcat

Some bold text

', - ]); - $page->save(); - - $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); - $resp->assertSee("# Dogcat\n\nSome **bold** text"); - } - - public function test_chapter_markdown_export() - { - $chapter = $this->entities->chapter(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); - - $resp->assertSee('# ' . $book->name); - $resp->assertSee('# ' . $chapter->name); - $resp->assertSee('# ' . $page->name); - } - - public function test_book_markdown_export_concats_immediate_pages_with_newlines() - { - /** @var Book $book */ - $book = Book::query()->whereHas('pages')->first(); - - $this->asEditor()->get($book->getUrl('/create-page')); - $this->get($book->getUrl('/create-page')); - - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); - $pageA->html = '

hello tester

'; - $pageA->save(); - $pageB->name = 'The second page in this test'; - $pageB->save(); - - $resp = $this->get($book->getUrl('/export/markdown')); - $resp->assertDontSee('hello tester# The second page in this test'); - $resp->assertSee("hello tester\n\n# The second page in this test"); - } - - public function test_export_option_only_visible_and_accessible_with_permission() - { - $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); - $chapter = $book->chapters()->first(); - $page = $chapter->pages()->first(); - $entities = [$book, $chapter, $page]; - $user = $this->users->viewer(); - $this->actingAs($user); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertSee('/export/pdf'); - } - - $this->permissions->removeUserRolePermissions($user, ['content-export']); - - foreach ($entities as $entity) { - $resp = $this->get($entity->getUrl()); - $resp->assertDontSee('/export/pdf'); - $resp = $this->get($entity->getUrl('/export/pdf')); - $this->assertPermissionError($resp); - } - } - - public function test_wkhtmltopdf_only_used_when_allow_untrusted_is_true() - { - $page = $this->entities->page(); - - config()->set('exports.snappy.pdf_binary', '/abc123'); - config()->set('app.allow_untrusted_server_fetching', false); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. - - config()->set('app.allow_untrusted_server_fetching', true); - $resp = $this->get($page->getUrl('/export/pdf')); - $resp->assertStatus(500); // Bad response indicates wkhtml usage - } - - public function test_pdf_command_option_used_if_set() - { - $page = $this->entities->page(); - $command = 'cp {input_html_path} {output_pdf_path}'; - config()->set('exports.pdf_command', $command); - - $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); - $download = $resp->getContent(); - - $this->assertStringContainsString(e($page->name), $download); - $this->assertStringContainsString('set('exports.pdf_command', $command); - - $this->assertThrows(function () use ($page) { - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - }, PdfExportException::class); - } - - public function test_pdf_command_option_errors_if_command_returns_error_status() - { - $page = $this->entities->page(); - $command = 'exit 1'; - config()->set('exports.pdf_command', $command); - - $this->assertThrows(function () use ($page) { - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - }, PdfExportException::class); - } - - public function test_pdf_command_timout_option_limits_export_time() - { - $page = $this->entities->page(); - $command = 'php -r \'sleep(4);\''; - config()->set('exports.pdf_command', $command); - config()->set('exports.pdf_command_timeout', 1); - - $this->assertThrows(function () use ($page) { - $start = time(); - $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); - - $this->assertTrue(time() < ($start + 3)); - }, PdfExportException::class, - "PDF Export via command failed due to timeout at 1 second(s)"); - } - - public function test_html_exports_contain_csp_meta_tag() - { - $entities = [ - $this->entities->page(), - $this->entities->book(), - $this->entities->chapter(), - ]; - - foreach ($entities as $entity) { - $resp = $this->asEditor()->get($entity->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); - } - } - - public function test_html_exports_contain_body_classes_for_export_identification() - { - $page = $this->entities->page(); - - $resp = $this->asEditor()->get($page->getUrl('/export/html')); - $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); - } -} diff --git a/tests/Exports/ExportUiTest.php b/tests/Exports/ExportUiTest.php new file mode 100644 index 00000000000..77b26ad8929 --- /dev/null +++ b/tests/Exports/ExportUiTest.php @@ -0,0 +1,33 @@ +whereHas('pages')->whereHas('chapters')->first(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $entities = [$book, $chapter, $page]; + $user = $this->users->viewer(); + $this->actingAs($user); + + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $resp->assertSee('/export/pdf'); + } + + $this->permissions->removeUserRolePermissions($user, ['content-export']); + + foreach ($entities as $entity) { + $resp = $this->get($entity->getUrl()); + $resp->assertDontSee('/export/pdf'); + $resp = $this->get($entity->getUrl('/export/pdf')); + $this->assertPermissionError($resp); + } + } +} diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php new file mode 100644 index 00000000000..069cf280167 --- /dev/null +++ b/tests/Exports/HtmlExportTest.php @@ -0,0 +1,253 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"'); + } + + public function test_book_html_export() + { + $page = $this->entities->page(); + $book = $page->book; + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.html"'); + } + + public function test_book_html_export_shows_html_descriptions() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $book->description_html = '

A description with HTML within!

'; + $chapter->description_html = '

A chapter description with HTML within!

'; + $book->save(); + $chapter->save(); + + $resp = $this->asEditor()->get($book->getUrl('/export/html')); + $resp->assertSee($book->description_html, false); + $resp->assertSee($chapter->description_html, false); + } + + public function test_chapter_html_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages[0]; + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.html"'); + } + + public function test_chapter_html_export_shows_html_descriptions() + { + $chapter = $this->entities->chapter(); + $chapter->description_html = '

A description with HTML within!

'; + $chapter->save(); + + $resp = $this->asEditor()->get($chapter->getUrl('/export/html')); + $resp->assertSee($chapter->description_html, false); + } + + public function test_page_html_export_contains_custom_head_if_set() + { + $page = $this->entities->page(); + + $customHeadContent = ''; + $this->setSettings(['app-custom-head' => $customHeadContent]); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee($customHeadContent, false); + } + + public function test_page_html_export_does_not_break_with_only_comments_in_custom_head() + { + $page = $this->entities->page(); + + $customHeadContent = ''; + $this->setSettings(['app-custom-head' => $customHeadContent]); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertSee($customHeadContent, false); + } + + public function test_page_html_export_use_absolute_dates() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee($page->created_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertDontSee($page->created_at->diffForHumans()); + $resp->assertSee($page->updated_at->isoFormat('D MMMM Y HH:mm:ss')); + $resp->assertDontSee($page->updated_at->diffForHumans()); + } + + public function test_page_export_does_not_include_user_or_revision_links() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee($page->getUrl('/revisions')); + $resp->assertDontSee($page->createdBy->getProfileUrl()); + $resp->assertSee($page->createdBy->name); + } + + public function test_page_export_sets_right_data_type_for_svg_embeds() + { + $page = $this->entities->page(); + Storage::disk('local')->makeDirectory('uploads/images/gallery'); + Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', ''); + $page->html = ''; + $page->save(); + + $this->asEditor(); + $resp = $this->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + + $resp->assertStatus(200); + $resp->assertSee(''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg'); + Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test'); + } + + public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder() + { + $page = $this->entities->page(); + $page->html = '' + . '' + . ''; + $storageDisk = Storage::disk('local'); + $storageDisk->makeDirectory('uploads/images/gallery'); + $storageDisk->put('uploads/images/gallery/svg_test.svg', 'good'); + $storageDisk->put('uploads/svg_test.svg', 'bad'); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + + $storageDisk->delete('uploads/images/gallery/svg_test.svg'); + $storageDisk->delete('uploads/svg_test.svg'); + + $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false); + $resp->assertSee('http://localhost/uploads/svg_test.svg'); + $resp->assertSee('src="/uploads/svg_test.svg"', false); + } + + public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local() + { + $contents = file_get_contents(public_path('.htaccess')); + config()->set('filesystems.images', 'local'); + + $page = $this->entities->page(); + $page->html = ''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee(base64_encode($contents)); + } + + public function test_page_export_contained_html_does_not_allow_upward_traversal_with_local_secure() + { + $testFilePath = storage_path('logs/test.txt'); + config()->set('filesystems.images', 'local_secure'); + file_put_contents($testFilePath, 'I am a cat'); + + $page = $this->entities->page(); + $page->html = ''; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertDontSee(base64_encode('I am a cat')); + unlink($testFilePath); + } + + public function test_exports_removes_scripts_from_custom_head() + { + $entities = [ + Page::query()->first(), Chapter::query()->first(), Book::query()->first(), + ]; + setting()->put('app-custom-head', ''); + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $resp->assertDontSee('window.donkey'); + $resp->assertDontSee('assertSee('.my-test-class { color: red; }'); + } + } + + public function test_page_export_with_deleted_creator_and_updater() + { + $user = $this->users->viewer(['name' => 'ExportWizardTheFifth']); + $page = $this->entities->page(); + $page->created_by = $user->id; + $page->updated_by = $user->id; + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $resp->assertSee('ExportWizardTheFifth'); + + $user->delete(); + $resp = $this->get($page->getUrl('/export/html')); + $resp->assertStatus(200); + $resp->assertDontSee('ExportWizardTheFifth'); + } + + public function test_html_exports_contain_csp_meta_tag() + { + $entities = [ + $this->entities->page(), + $this->entities->book(), + $this->entities->chapter(), + ]; + + foreach ($entities as $entity) { + $resp = $this->asEditor()->get($entity->getUrl('/export/html')); + $this->withHtml($resp)->assertElementExists('head meta[http-equiv="Content-Security-Policy"][content*="script-src "]'); + } + } + + public function test_html_exports_contain_body_classes_for_export_identification() + { + $page = $this->entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/html')); + $this->withHtml($resp)->assertElementExists('body.export.export-format-html.export-engine-none'); + } +} diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php new file mode 100644 index 00000000000..05ebbc68d75 --- /dev/null +++ b/tests/Exports/MarkdownExportTest.php @@ -0,0 +1,85 @@ +entities->page(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.md"'); + } + + public function test_page_markdown_export_uses_existing_markdown_if_apparent() + { + $page = $this->entities->page()->forceFill([ + 'markdown' => '# A header', + 'html' => '

Dogcat

', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee('A header'); + $resp->assertDontSee('Dogcat'); + } + + public function test_page_markdown_export_converts_html_where_no_markdown() + { + $page = $this->entities->page()->forceFill([ + 'markdown' => '', + 'html' => '

Dogcat

Some bold text

', + ]); + $page->save(); + + $resp = $this->asEditor()->get($page->getUrl('/export/markdown')); + $resp->assertSee("# Dogcat\n\nSome **bold** text"); + } + + public function test_chapter_markdown_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $page->name); + } + + public function test_book_markdown_export() + { + $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); + + $resp->assertSee('# ' . $book->name); + $resp->assertSee('# ' . $chapter->name); + $resp->assertSee('# ' . $page->name); + } + + public function test_book_markdown_export_concats_immediate_pages_with_newlines() + { + /** @var Book $book */ + $book = Book::query()->whereHas('pages')->first(); + + $this->asEditor()->get($book->getUrl('/create-page')); + $this->get($book->getUrl('/create-page')); + + [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); + $pageA->html = '

hello tester

'; + $pageA->save(); + $pageB->name = 'The second page in this test'; + $pageB->save(); + + $resp = $this->get($book->getUrl('/export/markdown')); + $resp->assertDontSee('hello tester# The second page in this test'); + $resp->assertSee("hello tester\n\n# The second page in this test"); + } +} diff --git a/tests/Exports/PdfExportTest.php b/tests/Exports/PdfExportTest.php new file mode 100644 index 00000000000..9d85c69e23a --- /dev/null +++ b/tests/Exports/PdfExportTest.php @@ -0,0 +1,146 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"'); + } + + public function test_book_pdf_export() + { + $page = $this->entities->page(); + $book = $page->book; + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.pdf"'); + } + + public function test_chapter_pdf_export() + { + $chapter = $this->entities->chapter(); + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/pdf')); + $resp->assertStatus(200); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.pdf"'); + } + + + public function test_page_pdf_export_converts_iframes_to_links() + { + $page = Page::query()->first()->forceFill([ + 'html' => '', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringNotContainsString('iframe>', $pdfHtml); + $this->assertStringContainsString('

https://www.youtube.com/embed/ShqUjt33uOs

', $pdfHtml); + } + + public function test_page_pdf_export_opens_details_blocks() + { + $page = $this->entities->page()->forceFill([ + 'html' => '
Hello

Content!

', + ]); + $page->save(); + + $pdfHtml = ''; + $mockPdfGenerator = $this->mock(PdfGenerator::class); + $mockPdfGenerator->shouldReceive('fromHtml') + ->with(\Mockery::capture($pdfHtml)) + ->andReturn(''); + $mockPdfGenerator->shouldReceive('getActiveEngine')->andReturn(PdfGenerator::ENGINE_DOMPDF); + + $this->asEditor()->get($page->getUrl('/export/pdf')); + $this->assertStringContainsString('
entities->page(); + + config()->set('exports.snappy.pdf_binary', '/abc123'); + config()->set('app.allow_untrusted_server_fetching', false); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $resp->assertStatus(200); // Sucessful response with invalid snappy binary indicates dompdf usage. + + config()->set('app.allow_untrusted_server_fetching', true); + $resp = $this->get($page->getUrl('/export/pdf')); + $resp->assertStatus(500); // Bad response indicates wkhtml usage + } + + public function test_pdf_command_option_used_if_set() + { + $page = $this->entities->page(); + $command = 'cp {input_html_path} {output_pdf_path}'; + config()->set('exports.pdf_command', $command); + + $resp = $this->asEditor()->get($page->getUrl('/export/pdf')); + $download = $resp->getContent(); + + $this->assertStringContainsString(e($page->name), $download); + $this->assertStringContainsString('set('exports.pdf_command', $command); + + $this->assertThrows(function () use ($page) { + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + }, PdfExportException::class); + } + + public function test_pdf_command_option_errors_if_command_returns_error_status() + { + $page = $this->entities->page(); + $command = 'exit 1'; + config()->set('exports.pdf_command', $command); + + $this->assertThrows(function () use ($page) { + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + }, PdfExportException::class); + } + + public function test_pdf_command_timout_option_limits_export_time() + { + $page = $this->entities->page(); + $command = 'php -r \'sleep(4);\''; + config()->set('exports.pdf_command', $command); + config()->set('exports.pdf_command_timeout', 1); + + $this->assertThrows(function () use ($page) { + $start = time(); + $this->withoutExceptionHandling()->asEditor()->get($page->getUrl('/export/pdf')); + + $this->assertTrue(time() < ($start + 3)); + }, PdfExportException::class, + "PDF Export via command failed due to timeout at 1 second(s)"); + } +} diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php new file mode 100644 index 00000000000..c593a6585cb --- /dev/null +++ b/tests/Exports/TextExportTest.php @@ -0,0 +1,88 @@ +entities->page(); + $this->asEditor(); + + $resp = $this->get($page->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($page->name); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"'); + } + + public function test_book_text_export() + { + $book = $this->entities->bookHasChaptersAndPages(); + $directPage = $book->directPages()->first(); + $chapter = $book->chapters()->first(); + $chapterPage = $chapter->pages()->first(); + $this->entities->updatePage($directPage, ['html' => '

My awesome page

']); + $this->entities->updatePage($chapterPage, ['html' => '

My little nested page

']); + $this->asEditor(); + + $resp = $this->get($book->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($book->name); + $resp->assertSee($chapterPage->name); + $resp->assertSee($chapter->name); + $resp->assertSee($directPage->name); + $resp->assertSee('My awesome page'); + $resp->assertSee('My little nested page'); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"'); + } + + public function test_book_text_export_format() + { + $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); + $this->entities->updatePage($entities['page'], ['html' => '

My great page

Full of great stuff

', 'name' => 'My wonderful page!']); + $entities['chapter']->name = 'Export chapter'; + $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; + $entities['book']->name = 'Export Book'; + $entities['book']->description = "This is a book with stuff to export"; + $entities['chapter']->save(); + $entities['book']->save(); + + $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); + + $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; + $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $resp->assertSee($expected); + } + + public function test_chapter_text_export() + { + $chapter = $this->entities->chapter(); + $page = $chapter->pages[0]; + $this->entities->updatePage($page, ['html' => '

This is content within the page!

']); + $this->asEditor(); + + $resp = $this->get($chapter->getUrl('/export/plaintext')); + $resp->assertStatus(200); + $resp->assertSee($chapter->name); + $resp->assertSee($page->name); + $resp->assertSee('This is content within the page!'); + $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"'); + } + + public function test_chapter_text_export_format() + { + $entities = $this->entities->createChainBelongingToUser($this->users->viewer()); + $this->entities->updatePage($entities['page'], ['html' => '

My great page

Full of great stuff

', 'name' => 'My wonderful page!']); + $entities['chapter']->name = 'Export chapter'; + $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within"; + $entities['chapter']->save(); + + $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); + + $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; + $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $resp->assertSee($expected); + } +} From 21ccfa97ddfe6309ff735aafbc8138cf34782563 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 19 Oct 2024 15:41:07 +0100 Subject: [PATCH 09/72] ZIP Export: Expanded page & added base attachment handling --- .../Controllers/PageExportController.php | 13 ++++ app/Exports/ZipExportBuilder.php | 66 +++++++++++++++++-- app/Exports/ZipExportFiles.php | 58 ++++++++++++++++ app/Uploads/AttachmentService.php | 11 +--- lang/en/entities.php | 1 + .../views/entities/export-menu.blade.php | 1 + routes/web.php | 1 + tests/Exports/ZipExportTest.php | 15 +++++ 8 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 app/Exports/ZipExportFiles.php create mode 100644 tests/Exports/ZipExportTest.php diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index a4e7aae879d..01611fd2121 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; @@ -74,4 +75,16 @@ public function markdown(string $bookSlug, string $pageSlug) return $this->download()->directly($pageText, $pageSlug . '.md'); } + + /** + * Export a page to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder) + { + $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $zip = $builder->buildForPage($page); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/ZipExportBuilder.php b/app/Exports/ZipExportBuilder.php index d1a7b6bd461..2b8b45d0d2a 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -2,24 +2,70 @@ namespace BookStack\Exports; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ZipExportException; +use BookStack\Uploads\Attachment; use ZipArchive; class ZipExportBuilder { protected array $data = []; + public function __construct( + protected ZipExportFiles $files + ) { + } + /** * @throws ZipExportException */ public function buildForPage(Page $page): string { - $this->data['page'] = [ - 'id' => $page->id, + $this->data['page'] = $this->convertPage($page); + return $this->build(); + } + + protected function convertPage(Page $page): array + { + $tags = array_map($this->convertTag(...), $page->tags()->get()->all()); + $attachments = array_map($this->convertAttachment(...), $page->attachments()->get()->all()); + + return [ + 'id' => $page->id, + 'name' => $page->name, + 'html' => '', // TODO + 'markdown' => '', // TODO + 'priority' => $page->priority, + 'attachments' => $attachments, + 'images' => [], // TODO + 'tags' => $tags, ]; + } - return $this->build(); + protected function convertAttachment(Attachment $attachment): array + { + $data = [ + 'name' => $attachment->name, + 'order' => $attachment->order, + ]; + + if ($attachment->external) { + $data['link'] = $attachment->path; + } else { + $data['file'] = $this->files->referenceForAttachment($attachment); + } + + return $data; + } + + protected function convertTag(Tag $tag): array + { + return [ + 'name' => $tag->name, + 'value' => $tag->value, + 'order' => $tag->order, + ]; } /** @@ -29,7 +75,7 @@ protected function build(): string { $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ - 'version' => trim(file_get_contents(base_path('version'))), + 'version' => trim(file_get_contents(base_path('version'))), 'id_ciphertext' => encrypt('bookstack'), ]; @@ -43,6 +89,18 @@ protected function build(): string $zip->addFromString('data.json', json_encode($this->data)); $zip->addEmptyDir('files'); + $toRemove = []; + $this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) { + $zip->addFile($filePath, "files/$fileRef"); + $toRemove[] = $filePath; + }); + + $zip->close(); + + foreach ($toRemove as $file) { + unlink($file); + } + return $zipFile; } } diff --git a/app/Exports/ZipExportFiles.php b/app/Exports/ZipExportFiles.php new file mode 100644 index 00000000000..d3ee70e93f2 --- /dev/null +++ b/app/Exports/ZipExportFiles.php @@ -0,0 +1,58 @@ + + */ + protected array $attachmentRefsById = []; + + public function __construct( + protected AttachmentService $attachmentService, + ) { + } + + /** + * Gain a reference to the given attachment instance. + * This is expected to be a file-based attachment that the user + * has visibility of, no permission/access checks are performed here. + */ + public function referenceForAttachment(Attachment $attachment): string + { + if (isset($this->attachmentRefsById[$attachment->id])) { + return $this->attachmentRefsById[$attachment->id]; + } + + do { + $fileName = Str::random(20) . '.' . $attachment->extension; + } while (in_array($fileName, $this->attachmentRefsById)); + + $this->attachmentRefsById[$attachment->id] = $fileName; + + return $fileName; + } + + /** + * Extract each of the ZIP export tracked files. + * Calls the given callback for each tracked file, passing a temporary + * file reference of the file contents, and the zip-local tracked reference. + */ + public function extractEach(callable $callback): void + { + foreach ($this->attachmentRefsById as $attachmentId => $ref) { + $attachment = Attachment::query()->find($attachmentId); + $stream = $this->attachmentService->streamAttachmentFromStorage($attachment); + $tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-'); + $tmpFileStream = fopen($tmpFile, 'w'); + stream_copy_to_stream($stream, $tmpFileStream); + $callback($tmpFile, $ref); + } + } +} diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index bd319fbd795..227649d8f00 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -13,14 +13,9 @@ class AttachmentService { - protected FilesystemManager $fileSystem; - - /** - * AttachmentService constructor. - */ - public function __construct(FilesystemManager $fileSystem) - { - $this->fileSystem = $fileSystem; + public function __construct( + protected FilesystemManager $fileSystem + ) { } /** diff --git a/lang/en/entities.php b/lang/en/entities.php index 35e6f050bb8..7e5a708ef62 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -39,6 +39,7 @@ 'export_pdf' => 'PDF File', 'export_text' => 'Plain Text File', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', diff --git a/resources/views/entities/export-menu.blade.php b/resources/views/entities/export-menu.blade.php index a55ab56d199..e58c842ba42 100644 --- a/resources/views/entities/export-menu.blade.php +++ b/resources/views/entities/export-menu.blade.php @@ -18,6 +18,7 @@ class="icon-list-item"
  • {{ trans('entities.export_pdf') }}.pdf
  • {{ trans('entities.export_text') }}.txt
  • {{ trans('entities.export_md') }}.md
  • +
  • {{ trans('entities.export_zip') }}.zip
  • diff --git a/routes/web.php b/routes/web.php index 5220684c0b0..6ae70983d52 100644 --- a/routes/web.php +++ b/routes/web.php @@ -91,6 +91,7 @@ Route::get('/books/{bookSlug}/page/{pageSlug}/export/html', [ExportControllers\PageExportController::class, 'html']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/markdown', [ExportControllers\PageExportController::class, 'markdown']); Route::get('/books/{bookSlug}/page/{pageSlug}/export/plaintext', [ExportControllers\PageExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/page/{pageSlug}/export/zip', [ExportControllers\PageExportController::class, 'zip']); Route::get('/books/{bookSlug}/page/{pageSlug}/edit', [EntityControllers\PageController::class, 'edit']); Route::get('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'showMove']); Route::put('/books/{bookSlug}/page/{pageSlug}/move', [EntityControllers\PageController::class, 'move']); diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php new file mode 100644 index 00000000000..d8ce00be33f --- /dev/null +++ b/tests/Exports/ZipExportTest.php @@ -0,0 +1,15 @@ +entities->page(); + // TODO + } +} From 7c39dd5cba7b72184adb96a8236ca1a3c99f03e3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 20 Oct 2024 19:56:56 +0100 Subject: [PATCH 10/72] ZIP Export: Started building link/ref handling --- app/Exports/ZipExportBuilder.php | 54 +++---------- .../ZipExportModels/ZipExportAttachment.php | 37 +++++++++ .../ZipExportModels/ZipExportImage.php | 11 +++ .../ZipExportModels/ZipExportModel.php | 11 +++ app/Exports/ZipExportModels/ZipExportPage.php | 39 ++++++++++ app/Exports/ZipExportModels/ZipExportTag.php | 27 +++++++ app/Exports/ZipExportReferences.php | 55 ++++++++++++++ app/Exports/ZipReferenceParser.php | 75 +++++++++++++++++++ dev/docs/portable-zip-file-format.md | 1 + 9 files changed, 265 insertions(+), 45 deletions(-) create mode 100644 app/Exports/ZipExportModels/ZipExportAttachment.php create mode 100644 app/Exports/ZipExportModels/ZipExportImage.php create mode 100644 app/Exports/ZipExportModels/ZipExportModel.php create mode 100644 app/Exports/ZipExportModels/ZipExportPage.php create mode 100644 app/Exports/ZipExportModels/ZipExportTag.php create mode 100644 app/Exports/ZipExportReferences.php create mode 100644 app/Exports/ZipReferenceParser.php diff --git a/app/Exports/ZipExportBuilder.php b/app/Exports/ZipExportBuilder.php index 2b8b45d0d2a..720b4997d4a 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -2,10 +2,9 @@ namespace BookStack\Exports; -use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ZipExportException; -use BookStack\Uploads\Attachment; +use BookStack\Exports\ZipExportModels\ZipExportPage; use ZipArchive; class ZipExportBuilder @@ -13,7 +12,8 @@ class ZipExportBuilder protected array $data = []; public function __construct( - protected ZipExportFiles $files + protected ZipExportFiles $files, + protected ZipExportReferences $references, ) { } @@ -22,50 +22,12 @@ public function __construct( */ public function buildForPage(Page $page): string { - $this->data['page'] = $this->convertPage($page); - return $this->build(); - } + $exportPage = ZipExportPage::fromModel($page, $this->files); + $this->data['page'] = $exportPage; - protected function convertPage(Page $page): array - { - $tags = array_map($this->convertTag(...), $page->tags()->get()->all()); - $attachments = array_map($this->convertAttachment(...), $page->attachments()->get()->all()); - - return [ - 'id' => $page->id, - 'name' => $page->name, - 'html' => '', // TODO - 'markdown' => '', // TODO - 'priority' => $page->priority, - 'attachments' => $attachments, - 'images' => [], // TODO - 'tags' => $tags, - ]; - } - - protected function convertAttachment(Attachment $attachment): array - { - $data = [ - 'name' => $attachment->name, - 'order' => $attachment->order, - ]; + $this->references->addPage($exportPage); - if ($attachment->external) { - $data['link'] = $attachment->path; - } else { - $data['file'] = $this->files->referenceForAttachment($attachment); - } - - return $data; - } - - protected function convertTag(Tag $tag): array - { - return [ - 'name' => $tag->name, - 'value' => $tag->value, - 'order' => $tag->order, - ]; + return $this->build(); } /** @@ -73,6 +35,8 @@ protected function convertTag(Tag $tag): array */ protected function build(): string { + $this->references->buildReferences(); + $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ 'version' => trim(file_get_contents(base_path('version'))), diff --git a/app/Exports/ZipExportModels/ZipExportAttachment.php b/app/Exports/ZipExportModels/ZipExportAttachment.php new file mode 100644 index 00000000000..d6d674a913f --- /dev/null +++ b/app/Exports/ZipExportModels/ZipExportAttachment.php @@ -0,0 +1,37 @@ +id = $model->id; + $instance->name = $model->name; + + if ($model->external) { + $instance->link = $model->path; + } else { + $instance->file = $files->referenceForAttachment($model); + } + + return $instance; + } + + public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Attachment $attachment) use ($files) { + return self::fromModel($attachment, $files); + }, $attachmentArray)); + } +} diff --git a/app/Exports/ZipExportModels/ZipExportImage.php b/app/Exports/ZipExportModels/ZipExportImage.php new file mode 100644 index 00000000000..73fe3bbf594 --- /dev/null +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -0,0 +1,11 @@ +id = $model->id; + $instance->name = $model->name; + $instance->html = (new PageContent($model))->render(); + + if (!empty($model->markdown)) { + $instance->markdown = $model->markdown; + } + + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + $instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files); + + return $instance; + } +} diff --git a/app/Exports/ZipExportModels/ZipExportTag.php b/app/Exports/ZipExportModels/ZipExportTag.php new file mode 100644 index 00000000000..636c9ff6dc4 --- /dev/null +++ b/app/Exports/ZipExportModels/ZipExportTag.php @@ -0,0 +1,27 @@ +name = $model->name; + $instance->value = $model->value; + $instance->order = $model->order; + + return $instance; + } + + public static function fromModelArray(array $tagArray): array + { + return array_values(array_map(self::fromModel(...), $tagArray)); + } +} diff --git a/app/Exports/ZipExportReferences.php b/app/Exports/ZipExportReferences.php new file mode 100644 index 00000000000..89deb7edad3 --- /dev/null +++ b/app/Exports/ZipExportReferences.php @@ -0,0 +1,55 @@ +id) { + $this->pages[$page->id] = $page; + } + + foreach ($page->attachments as $attachment) { + if ($attachment->id) { + $this->attachments[$attachment->id] = $attachment; + } + } + } + + public function buildReferences(): void + { + // TODO - References to images, attachments, other entities + + // TODO - Parse page MD & HTML + foreach ($this->pages as $page) { + $page->html = $this->parser->parse($page->html ?? '', function (Model $model): ?string { + // TODO - Handle found link to $model + // - Validate we can see/access $model, or/and that it's + // part of the export in progress. + return '[CAT]'; + }); + // TODO - markdown + } + + // TODO - Parse chapter desc html + // TODO - Parse book desc html + } +} diff --git a/app/Exports/ZipReferenceParser.php b/app/Exports/ZipReferenceParser.php new file mode 100644 index 00000000000..6ca826bc3f4 --- /dev/null +++ b/app/Exports/ZipReferenceParser.php @@ -0,0 +1,75 @@ +modelResolvers = [ + new PagePermalinkModelResolver($queries->pages), + new PageLinkModelResolver($queries->pages), + new ChapterLinkModelResolver($queries->chapters), + new BookLinkModelResolver($queries->books), + // TODO - Image + // TODO - Attachment + ]; + } + + /** + * Parse and replace references in the given content. + * @param callable(Model):(string|null) $handler + */ + public function parse(string $content, callable $handler): string + { + $escapedBase = preg_quote(url('/'), '/'); + $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#]/"; + $matches = []; + preg_match_all($linkRegex, $content, $matches); + + if (count($matches) < 2) { + return $content; + } + + foreach ($matches[1] as $link) { + $model = $this->linkToModel($link); + if ($model) { + $result = $handler($model); + if ($result !== null) { + $content = str_replace($link, $result, $content); + } + } + } + + return $content; + } + + + /** + * Attempt to resolve the given link to a model using the instance model resolvers. + */ + protected function linkToModel(string $link): ?Model + { + foreach ($this->modelResolvers as $resolver) { + $model = $resolver->resolve($link); + if (!is_null($model)) { + return $model; + } + } + + return null; + } +} diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index d5635bd398d..7a99563d14b 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -128,6 +128,7 @@ File must be an image type accepted by BookStack (png, jpg, gif, webp) #### Attachment +- `id` - Number, optional, original ID for the attachment from exported system. - `name` - String, required, name of attachment. - `link` - String, semi-optional, URL of attachment. - `file` - String reference, semi-optional, reference to attachment file. From 3e656efb0088b180665e224a68adde061e86786b Mon Sep 17 00:00:00 2001 From: Rashad Date: Mon, 21 Oct 2024 02:42:49 +0530 Subject: [PATCH 11/72] Added include func for search api --- app/Api/ApiEntityListFormatter.php | 42 ++++++++++- app/Search/SearchApiController.php | 69 ++++++++++++++--- dev/api/requests/search-all.http | 2 +- dev/api/responses/search-all.json | 7 +- tests/Api/SearchApiTest.php | 117 ++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 20 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 436d66d598e..23fa8e6ea72 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -3,6 +3,7 @@ namespace BookStack\Api; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; class ApiEntityListFormatter { @@ -12,6 +13,11 @@ class ApiEntityListFormatter */ protected array $list = []; + /** + * Whether to include related titles in the response. + */ + protected bool $includeRelatedTitles = false; + /** * The fields to show in the formatted data. * Can be a plain string array item for a direct model field (If existing on model). @@ -20,8 +26,16 @@ class ApiEntityListFormatter * @var array */ protected array $fields = [ - 'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft', - 'template', 'priority', 'created_at', 'updated_at', + 'id', + 'name', + 'slug', + 'book_id', + 'chapter_id', + 'draft', + 'template', + 'priority', + 'created_at', + 'updated_at', ]; public function __construct(array $list) @@ -62,6 +76,30 @@ public function withTags(): self return $this; } + /** + * Enable the inclusion of related book and chapter titles in the response. + */ + public function withRelatedTitles(): self + { + $this->includeRelatedTitles = true; + + $this->withField('book_title', function (Entity $entity) { + if (method_exists($entity, 'book')) { + return $entity->book?->name; + } + return null; + }); + + $this->withField('chapter_title', function (Entity $entity) { + if ($entity instanceof Page && $entity->chapter_id) { + return optional($entity->getAttribute('chapter'))->name; + } + return null; + }); + + return $this; + } + /** * Format the data and return an array of formatted content. * @return array[] diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index d1619e11867..5072bd3b463 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -14,12 +14,23 @@ class SearchApiController extends ApiController protected $rules = [ 'all' => [ - 'query' => ['required'], - 'page' => ['integer', 'min:1'], - 'count' => ['integer', 'min:1', 'max:100'], + 'query' => ['required'], + 'page' => ['integer', 'min:1'], + 'count' => ['integer', 'min:1', 'max:100'], + 'include' => ['string', 'regex:/^[a-zA-Z,]*$/'], ], ]; + /** + * Valid include parameters and their corresponding formatter methods. + * These parameters allow for additional related data, like titles or tags, + * to be included in the search results when requested via the API. + */ + protected const VALID_INCLUDES = [ + 'titles' => 'withRelatedTitles', + 'tags' => 'withTags', + ]; + public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) { $this->searchRunner = $searchRunner; @@ -33,6 +44,13 @@ public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $ * for a full list of search term options. Results contain a 'type' property to distinguish * between: bookshelf, book, chapter & page. * + * This method now supports the 'include' parameter, which allows API clients to specify related + * fields (such as titles or tags) that should be included in the search results. + * + * The 'include' parameter is a comma-separated string. For example, adding `include=titles,tags` + * will include both titles and tags in the API response. If the parameter is not provided, only + * basic entity data will be returned. + * * The paging parameters and response format emulates a standard listing endpoint * but standard sorting and filtering cannot be done on this endpoint. If a count value * is provided this will only be taken as a suggestion. The results in the response @@ -45,22 +63,49 @@ public function all(Request $request) $options = SearchOptions::fromString($request->get('query') ?? ''); $page = intval($request->get('page', '0')) ?: 1; $count = min(intval($request->get('count', '0')) ?: 20, 100); + $includes = $this->parseIncludes($request->get('include', '')); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $this->resultsFormatter->format($results['results']->all(), $options); - $data = (new ApiEntityListFormatter($results['results']->all())) - ->withType()->withTags() - ->withField('preview_html', function (Entity $entity) { - return [ - 'name' => (string) $entity->getAttribute('preview_name'), - 'content' => (string) $entity->getAttribute('preview_content'), - ]; - })->format(); + $formatter = new ApiEntityListFormatter($results['results']->all()); + $formatter->withType(); // Always include type as it's essential for search results + + foreach ($includes as $include) { + if (isset(self::VALID_INCLUDES[$include])) { + $method = self::VALID_INCLUDES[$include]; + $formatter->$method(); + } + } + + $formatter->withField('preview_html', function (Entity $entity) { + return [ + 'name' => (string) $entity->getAttribute('preview_name'), + 'content' => (string) $entity->getAttribute('preview_content'), + ]; + }); return response()->json([ - 'data' => $data, + 'data' => $formatter->format(), 'total' => $results['total'], ]); } + + /** + * Parse and validate the include parameter. + * + * @param string $includeString Comma-separated list of includes + * @return array + */ + protected function parseIncludes(string $includeString): array + { + if (empty($includeString)) { + return []; + } + + return array_filter( + explode(',', strtolower($includeString)), + fn($include) => isset (self::VALID_INCLUDES[$include]) + ); + } } diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http index ee522381641..7fa1a304e21 100644 --- a/dev/api/requests/search-all.http +++ b/dev/api/requests/search-all.http @@ -1 +1 @@ -GET /api/search?query=cats+{created_by:me}&page=1&count=2 \ No newline at end of file +GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index 2c7584e3fd0..bb45b795903 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -9,6 +9,7 @@ "updated_at": "2021-11-14T15:57:35.000000Z", "type": "chapter", "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", + "book_title": "Cats", "preview_html": { "name": "A chapter for cats", "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" @@ -27,6 +28,8 @@ "updated_at": "2021-11-14T15:56:49.000000Z", "type": "page", "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", + "book_title": "Cats", + "chapter_title": "A chapter for cats", "preview_html": { "name": "The hows and whys of cats", "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." @@ -56,6 +59,8 @@ "updated_at": "2021-11-14T16:02:39.000000Z", "type": "page", "url": "https://example.com/books/my-book/page/how-advanced-are-cats", + "book_title": "Cats", + "chapter_title": "A chapter for cats", "preview_html": { "name": "How advanced are cats?", "content": "cats are some of the most advanced animals in the world." @@ -64,4 +69,4 @@ } ], "total": 3 -} \ No newline at end of file +} diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 2a186e8d632..b80ed4530ba 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Api; +use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; @@ -45,7 +46,7 @@ public function test_all_endpoint_returns_entity_url() $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ 'type' => 'page', - 'url' => $page->getUrl(), + 'url' => $page->getUrl(), ]); } @@ -57,10 +58,10 @@ public function test_all_endpoint_returns_items_with_preview_html() $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ - 'type' => 'book', - 'url' => $book->getUrl(), + 'type' => 'book', + 'url' => $book->getUrl(), 'preview_html' => [ - 'name' => 'name with superuniquevalue within', + 'name' => 'name with superuniquevalue within', 'content' => 'Description with superuniquevalue within', ], ]); @@ -74,4 +75,112 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } + + public function test_all_endpoint_includes_book_and_chapter_titles_when_requested() + { + $this->actingAsApiEditor(); + + $book = $this->entities->book(); + $chapter = $this->entities->chapter(); + $page = $this->entities->newPage(); + + $book->name = 'My Test Book'; + $book->save(); + + $chapter->name = 'My Test Chapter'; + $chapter->book_id = $book->id; + $chapter->save(); + + $page->name = 'My Test Page With UniqueSearchTerm'; + $page->book_id = $book->id; + $page->chapter_id = $chapter->id; + $page->save(); + + $page->indexForSearch(); + + // Test without include parameter + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); + $resp->assertOk(); + $resp->assertDontSee('book_title'); + $resp->assertDontSee('chapter_title'); + + // Test with include parameter + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=titles'); + $resp->assertOk(); + $resp->assertJsonFragment([ + 'name' => 'My Test Page With UniqueSearchTerm', + 'book_title' => 'My Test Book', + 'chapter_title' => 'My Test Chapter', + 'type' => 'page' + ]); + } + + public function test_all_endpoint_validates_include_parameter() + { + $this->actingAsApiEditor(); + + // Test invalid include value + $resp = $this->getJson($this->baseEndpoint . '?query=test&include=invalid'); + $resp->assertOk(); + $resp->assertDontSee('book_title'); + + // Test SQL injection attempt + $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles;DROP TABLE users'); + $resp->assertStatus(422); + + // Test multiple includes + $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles,tags'); + $resp->assertOk(); + } + + public function test_all_endpoint_includes_tags_when_requested() + { + $this->actingAsApiEditor(); + + // Create a page and give it a unique name for search + $page = $this->entities->page(); + $page->name = 'Page With UniqueSearchTerm'; + $page->save(); + + // Save tags to the page using the existing saveTagsToEntity method + $tags = [ + ['name' => 'SampleTag', 'value' => 'SampleValue'] + ]; + app(\BookStack\Activity\TagRepo::class)->saveTagsToEntity($page, $tags); + + // Ensure the page is indexed for search + $page->indexForSearch(); + + // Test without the "tags" include + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); + $resp->assertOk(); + $resp->assertDontSee('tags'); + + // Test with the "tags" include + $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=tags'); + $resp->assertOk(); + + // Assert that tags are included in the response + $resp->assertJsonFragment([ + 'name' => 'SampleTag', + 'value' => 'SampleValue', + ]); + + // Optionally: check the structure to match the tag order as well + $resp->assertJsonStructure([ + 'data' => [ + '*' => [ + 'tags' => [ + '*' => [ + 'name', + 'value', + 'order', + ], + ], + ], + ], + ]); + } + + } From 90a80705180d003a1e896bc307d3abc1720366dc Mon Sep 17 00:00:00 2001 From: Rashad Date: Mon, 21 Oct 2024 03:01:33 +0530 Subject: [PATCH 12/72] Eager loading for titles --- app/Api/ApiEntityListFormatter.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 23fa8e6ea72..2fd9b7c5507 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -106,6 +106,10 @@ public function withRelatedTitles(): self */ public function format(): array { + if ($this->includeRelatedTitles) { + $this->loadRelatedTitles(); + } + $results = []; foreach ($this->list as $item) { @@ -115,6 +119,23 @@ public function format(): array return $results; } + /** + * Eager load the related book and chapter data when needed. + */ + protected function loadRelatedTitles(): void + { + $pages = collect($this->list)->filter(fn($item) => $item instanceof Page); + + foreach ($this->list as $entity) { + if (method_exists($entity, 'book')) { + $entity->load('book'); + } + if ($entity instanceof Page && $entity->chapter_id) { + $entity->load('chapter'); + } + } + } + /** * Format a single entity item to a plain array. */ From 06ffd8ee721a74dea9c584002b2793cc68c873a0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Oct 2024 12:13:41 +0100 Subject: [PATCH 13/72] Zip Exports: Added attachment/image link resolving & JSON null handling --- .../ZipExportModels/ZipExportAttachment.php | 2 +- .../ZipExportModels/ZipExportImage.php | 2 +- .../ZipExportModels/ZipExportModel.php | 17 +++++++--- app/Exports/ZipExportModels/ZipExportPage.php | 2 +- app/Exports/ZipExportModels/ZipExportTag.php | 2 +- app/Exports/ZipExportReferences.php | 3 ++ app/Exports/ZipReferenceParser.php | 6 ++-- .../AttachmentModelResolver.php | 22 +++++++++++++ .../ModelResolvers/ImageModelResolver.php | 33 +++++++++++++++++++ 9 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 app/References/ModelResolvers/AttachmentModelResolver.php create mode 100644 app/References/ModelResolvers/ImageModelResolver.php diff --git a/app/Exports/ZipExportModels/ZipExportAttachment.php b/app/Exports/ZipExportModels/ZipExportAttachment.php index d6d674a913f..d79a16cc109 100644 --- a/app/Exports/ZipExportModels/ZipExportAttachment.php +++ b/app/Exports/ZipExportModels/ZipExportAttachment.php @@ -5,7 +5,7 @@ use BookStack\Exports\ZipExportFiles; use BookStack\Uploads\Attachment; -class ZipExportAttachment implements ZipExportModel +class ZipExportAttachment extends ZipExportModel { public ?int $id = null; public string $name; diff --git a/app/Exports/ZipExportModels/ZipExportImage.php b/app/Exports/ZipExportModels/ZipExportImage.php index 73fe3bbf594..540d3d4e55d 100644 --- a/app/Exports/ZipExportModels/ZipExportImage.php +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -4,7 +4,7 @@ use BookStack\Activity\Models\Tag; -class ZipExportImage implements ZipExportModel +class ZipExportImage extends ZipExportModel { public string $name; public string $file; diff --git a/app/Exports/ZipExportModels/ZipExportModel.php b/app/Exports/ZipExportModels/ZipExportModel.php index e1cb616de52..26b994c018e 100644 --- a/app/Exports/ZipExportModels/ZipExportModel.php +++ b/app/Exports/ZipExportModels/ZipExportModel.php @@ -2,10 +2,19 @@ namespace BookStack\Exports\ZipExportModels; -use BookStack\App\Model; -use BookStack\Exports\ZipExportFiles; +use JsonSerializable; -interface ZipExportModel +abstract class ZipExportModel implements JsonSerializable { -// public static function fromModel(Model $model, ZipExportFiles $files): self; + /** + * Handle the serialization to JSON. + * For these exports, we filter out optional (represented as nullable) fields + * just to clean things up and prevent confusion to avoid null states in the + * resulting export format itself. + */ + public function jsonSerialize(): array + { + $publicProps = get_object_vars(...)->__invoke($this); + return array_filter($publicProps, fn ($value) => $value !== null); + } } diff --git a/app/Exports/ZipExportModels/ZipExportPage.php b/app/Exports/ZipExportModels/ZipExportPage.php index 6589ce60ae1..c7a9503546f 100644 --- a/app/Exports/ZipExportModels/ZipExportPage.php +++ b/app/Exports/ZipExportModels/ZipExportPage.php @@ -6,7 +6,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exports\ZipExportFiles; -class ZipExportPage implements ZipExportModel +class ZipExportPage extends ZipExportModel { public ?int $id = null; public string $name; diff --git a/app/Exports/ZipExportModels/ZipExportTag.php b/app/Exports/ZipExportModels/ZipExportTag.php index 636c9ff6dc4..09ae9f06cbe 100644 --- a/app/Exports/ZipExportModels/ZipExportTag.php +++ b/app/Exports/ZipExportModels/ZipExportTag.php @@ -4,7 +4,7 @@ use BookStack\Activity\Models\Tag; -class ZipExportTag implements ZipExportModel +class ZipExportTag extends ZipExportModel { public string $name; public ?string $value = null; diff --git a/app/Exports/ZipExportReferences.php b/app/Exports/ZipExportReferences.php index 89deb7edad3..76a7fedbec4 100644 --- a/app/Exports/ZipExportReferences.php +++ b/app/Exports/ZipExportReferences.php @@ -44,11 +44,14 @@ public function buildReferences(): void // TODO - Handle found link to $model // - Validate we can see/access $model, or/and that it's // part of the export in progress. + + // TODO - Add images after the above to files return '[CAT]'; }); // TODO - markdown } +// dd('end'); // TODO - Parse chapter desc html // TODO - Parse book desc html } diff --git a/app/Exports/ZipReferenceParser.php b/app/Exports/ZipReferenceParser.php index 6ca826bc3f4..820920da28d 100644 --- a/app/Exports/ZipReferenceParser.php +++ b/app/Exports/ZipReferenceParser.php @@ -4,9 +4,11 @@ use BookStack\App\Model; use BookStack\Entities\Queries\EntityQueries; +use BookStack\References\ModelResolvers\AttachmentModelResolver; use BookStack\References\ModelResolvers\BookLinkModelResolver; use BookStack\References\ModelResolvers\ChapterLinkModelResolver; use BookStack\References\ModelResolvers\CrossLinkModelResolver; +use BookStack\References\ModelResolvers\ImageModelResolver; use BookStack\References\ModelResolvers\PageLinkModelResolver; use BookStack\References\ModelResolvers\PagePermalinkModelResolver; @@ -24,8 +26,8 @@ public function __construct(EntityQueries $queries) new PageLinkModelResolver($queries->pages), new ChapterLinkModelResolver($queries->chapters), new BookLinkModelResolver($queries->books), - // TODO - Image - // TODO - Attachment + new ImageModelResolver(), + new AttachmentModelResolver(), ]; } diff --git a/app/References/ModelResolvers/AttachmentModelResolver.php b/app/References/ModelResolvers/AttachmentModelResolver.php new file mode 100644 index 00000000000..e870d515bbe --- /dev/null +++ b/app/References/ModelResolvers/AttachmentModelResolver.php @@ -0,0 +1,22 @@ +find($id); + } +} diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php new file mode 100644 index 00000000000..331dd593b73 --- /dev/null +++ b/app/References/ModelResolvers/ImageModelResolver.php @@ -0,0 +1,33 @@ +where('path', '=', $fullPath)->first(); + } +} From 4fb4fe0931d220ffb9d7e173388351047b665f4c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 21 Oct 2024 13:59:15 +0100 Subject: [PATCH 14/72] ZIP Exports: Added working image handling/inclusion --- app/Exports/ZipExportBuilder.php | 2 +- app/Exports/ZipExportFiles.php | 51 ++++++++++++++- .../ZipExportModels/ZipExportImage.php | 16 ++++- app/Exports/ZipExportReferences.php | 62 ++++++++++++++++--- app/Uploads/ImageService.php | 13 ++++ app/Uploads/ImageStorageDisk.php | 9 +++ dev/docs/portable-zip-file-format.md | 13 ++-- 7 files changed, 148 insertions(+), 18 deletions(-) diff --git a/app/Exports/ZipExportBuilder.php b/app/Exports/ZipExportBuilder.php index 720b4997d4a..5c56e531b40 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -35,7 +35,7 @@ public function buildForPage(Page $page): string */ protected function build(): string { - $this->references->buildReferences(); + $this->references->buildReferences($this->files); $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ diff --git a/app/Exports/ZipExportFiles.php b/app/Exports/ZipExportFiles.php index d3ee70e93f2..27b6f937a38 100644 --- a/app/Exports/ZipExportFiles.php +++ b/app/Exports/ZipExportFiles.php @@ -4,6 +4,8 @@ use BookStack\Uploads\Attachment; use BookStack\Uploads\AttachmentService; +use BookStack\Uploads\Image; +use BookStack\Uploads\ImageService; use Illuminate\Support\Str; class ZipExportFiles @@ -14,8 +16,15 @@ class ZipExportFiles */ protected array $attachmentRefsById = []; + /** + * References for images by image ID. + * @var array + */ + protected array $imageRefsById = []; + public function __construct( protected AttachmentService $attachmentService, + protected ImageService $imageService, ) { } @@ -30,15 +39,46 @@ public function referenceForAttachment(Attachment $attachment): string return $this->attachmentRefsById[$attachment->id]; } + $existingFiles = $this->getAllFileNames(); do { $fileName = Str::random(20) . '.' . $attachment->extension; - } while (in_array($fileName, $this->attachmentRefsById)); + } while (in_array($fileName, $existingFiles)); $this->attachmentRefsById[$attachment->id] = $fileName; return $fileName; } + /** + * Gain a reference to the given image instance. + * This is expected to be an image that the user has visibility of, + * no permission/access checks are performed here. + */ + public function referenceForImage(Image $image): string + { + if (isset($this->imageRefsById[$image->id])) { + return $this->imageRefsById[$image->id]; + } + + $existingFiles = $this->getAllFileNames(); + $extension = pathinfo($image->path, PATHINFO_EXTENSION); + do { + $fileName = Str::random(20) . '.' . $extension; + } while (in_array($fileName, $existingFiles)); + + $this->imageRefsById[$image->id] = $fileName; + + return $fileName; + } + + protected function getAllFileNames(): array + { + return array_merge( + array_values($this->attachmentRefsById), + array_values($this->imageRefsById), + ); + } + /** * Extract each of the ZIP export tracked files. * Calls the given callback for each tracked file, passing a temporary @@ -54,5 +94,14 @@ public function extractEach(callable $callback): void stream_copy_to_stream($stream, $tmpFileStream); $callback($tmpFile, $ref); } + + foreach ($this->imageRefsById as $imageId => $ref) { + $image = Image::query()->find($imageId); + $stream = $this->imageService->getImageStream($image); + $tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-'); + $tmpFileStream = fopen($tmpFile, 'w'); + stream_copy_to_stream($stream, $tmpFileStream); + $callback($tmpFile, $ref); + } } } diff --git a/app/Exports/ZipExportModels/ZipExportImage.php b/app/Exports/ZipExportModels/ZipExportImage.php index 540d3d4e55d..39f1d101298 100644 --- a/app/Exports/ZipExportModels/ZipExportImage.php +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -2,10 +2,24 @@ namespace BookStack\Exports\ZipExportModels; -use BookStack\Activity\Models\Tag; +use BookStack\Exports\ZipExportFiles; +use BookStack\Uploads\Image; class ZipExportImage extends ZipExportModel { + public ?int $id = null; public string $name; public string $file; + public string $type; + + public static function fromModel(Image $model, ZipExportFiles $files): self + { + $instance = new self(); + $instance->id = $model->id; + $instance->name = $model->name; + $instance->type = $model->type; + $instance->file = $files->referenceForImage($model); + + return $instance; + } } diff --git a/app/Exports/ZipExportReferences.php b/app/Exports/ZipExportReferences.php index 76a7fedbec4..19672db0a0e 100644 --- a/app/Exports/ZipExportReferences.php +++ b/app/Exports/ZipExportReferences.php @@ -3,8 +3,13 @@ namespace BookStack\Exports; use BookStack\App\Model; +use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExportModels\ZipExportAttachment; +use BookStack\Exports\ZipExportModels\ZipExportImage; +use BookStack\Exports\ZipExportModels\ZipExportModel; use BookStack\Exports\ZipExportModels\ZipExportPage; +use BookStack\Uploads\Attachment; +use BookStack\Uploads\Image; class ZipExportReferences { @@ -16,6 +21,9 @@ class ZipExportReferences /** @var ZipExportAttachment[] */ protected array $attachments = []; + /** @var ZipExportImage[] */ + protected array $images = []; + public function __construct( protected ZipReferenceParser $parser, ) { @@ -34,19 +42,12 @@ public function addPage(ZipExportPage $page): void } } - public function buildReferences(): void + public function buildReferences(ZipExportFiles $files): void { - // TODO - References to images, attachments, other entities - // TODO - Parse page MD & HTML foreach ($this->pages as $page) { - $page->html = $this->parser->parse($page->html ?? '', function (Model $model): ?string { - // TODO - Handle found link to $model - // - Validate we can see/access $model, or/and that it's - // part of the export in progress. - - // TODO - Add images after the above to files - return '[CAT]'; + $page->html = $this->parser->parse($page->html ?? '', function (Model $model) use ($files, $page) { + return $this->handleModelReference($model, $page, $files); }); // TODO - markdown } @@ -55,4 +56,45 @@ public function buildReferences(): void // TODO - Parse chapter desc html // TODO - Parse book desc html } + + protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string + { + // TODO - References to other entities + + // Handle attachment references + // No permission check needed here since they would only already exist in this + // reference context if already allowed via their entity access. + if ($model instanceof Attachment) { + if (isset($this->attachments[$model->id])) { + return "[[bsexport:attachment:{$model->id}]]"; + } + return null; + } + + // Handle image references + if ($model instanceof Image) { + // Only handle gallery and drawio images + if ($model->type !== 'gallery' && $model->type !== 'drawio') { + return null; + } + + // We don't expect images to be part of book/chapter content + if (!($exportModel instanceof ZipExportPage)) { + return null; + } + + $page = $model->getPage(); + if ($page && userCan('view', $page)) { + if (!isset($this->images[$model->id])) { + $exportImage = ZipExportImage::fromModel($model, $files); + $this->images[$model->id] = $exportImage; + $exportModel->images[] = $exportImage; + } + return "[[bsexport:image:{$model->id}]]"; + } + return null; + } + + return null; + } } diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 8d8da61ec18..e501cc7b12d 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -133,6 +133,19 @@ public function getImageData(Image $image): string return $disk->get($image->path); } + /** + * Get the raw data content from an image. + * + * @throws Exception + * @returns ?resource + */ + public function getImageStream(Image $image): mixed + { + $disk = $this->storage->getDisk(); + + return $disk->stream($image->path); + } + /** * Destroy an image along with its revisions, thumbnails and remaining folders. * diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php index 798b72abdbf..8df702e0d94 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -55,6 +55,15 @@ public function get(string $path): ?string return $this->filesystem->get($this->adjustPathForDisk($path)); } + /** + * Get a stream to the file at the given path. + * @returns ?resource + */ + public function stream(string $path): mixed + { + return $this->filesystem->readStream($this->adjustPathForDisk($path)); + } + /** * Save the given image data at the given path. Can choose to set * the image as public which will update its visibility after saving. diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 7a99563d14b..1ba5872018c 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -46,13 +46,12 @@ This can be done using the following format: [[bsexport::]] ``` -Images and attachments are referenced via their file name within the `files/` directory. -Otherwise, other content types are referenced by `id`. +References are to the `id` for data objects. Here's an example of each type of such reference that could be used: ``` -[[bsexport:image:an-image-path.png]] -[[bsexport:attachment:an-image-path.png]] +[[bsexport:image:22]] +[[bsexport:attachment:55]] [[bsexport:page:40]] [[bsexport:chapter:2]] [[bsexport:book:8]] @@ -121,10 +120,14 @@ The page editor type, and edit content will be determined by what content is pro #### Image +- `id` - Number, optional, original ID for the page from exported system. - `name` - String, required, name of image. - `file` - String reference, required, reference to image file. +- `type` - String, required, must be 'gallery' or 'drawio' -File must be an image type accepted by BookStack (png, jpg, gif, webp) +File must be an image type accepted by BookStack (png, jpg, gif, webp). +Images of type 'drawio' are expected to be png with draw.io drawing data +embedded within it. #### Attachment From f732ef05d5b60a48ce3b42e05155e9e91d92d927 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 23 Oct 2024 10:48:26 +0100 Subject: [PATCH 15/72] ZIP Exports: Reorganised files, added page md parsing --- .../Controllers/PageExportController.php | 2 +- .../Models}/ZipExportAttachment.php | 4 ++-- .../Models}/ZipExportImage.php | 4 ++-- .../Models}/ZipExportModel.php | 2 +- .../Models}/ZipExportPage.php | 4 ++-- .../Models}/ZipExportTag.php | 2 +- .../{ => ZipExports}/ZipExportBuilder.php | 4 ++-- .../{ => ZipExports}/ZipExportFiles.php | 2 +- .../{ => ZipExports}/ZipExportReferences.php | 23 +++++++++++-------- .../{ => ZipExports}/ZipReferenceParser.php | 2 +- 10 files changed, 26 insertions(+), 23 deletions(-) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportAttachment.php (90%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportImage.php (84%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportModel.php (92%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportPage.php (91%) rename app/Exports/{ZipExportModels => ZipExports/Models}/ZipExportTag.php (92%) rename app/Exports/{ => ZipExports}/ZipExportBuilder.php (94%) rename app/Exports/{ => ZipExports}/ZipExportFiles.php (98%) rename app/Exports/{ => ZipExports}/ZipExportReferences.php (83%) rename app/Exports/{ => ZipExports}/ZipReferenceParser.php (98%) diff --git a/app/Exports/Controllers/PageExportController.php b/app/Exports/Controllers/PageExportController.php index 01611fd2121..34e67ffcf70 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -6,7 +6,7 @@ use BookStack\Entities\Tools\PageContent; use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; -use BookStack\Exports\ZipExportBuilder; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; diff --git a/app/Exports/ZipExportModels/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php similarity index 90% rename from app/Exports/ZipExportModels/ZipExportAttachment.php rename to app/Exports/ZipExports/Models/ZipExportAttachment.php index d79a16cc109..8c89ae11f14 100644 --- a/app/Exports/ZipExportModels/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -1,8 +1,8 @@ pages as $page) { - $page->html = $this->parser->parse($page->html ?? '', function (Model $model) use ($files, $page) { + $handler = function (Model $model) use ($files, $page) { return $this->handleModelReference($model, $page, $files); - }); - // TODO - markdown + }; + + $page->html = $this->parser->parse($page->html ?? '', $handler); + if ($page->markdown) { + $page->markdown = $this->parser->parse($page->markdown, $handler); + } } // dd('end'); diff --git a/app/Exports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php similarity index 98% rename from app/Exports/ZipReferenceParser.php rename to app/Exports/ZipExports/ZipReferenceParser.php index 820920da28d..4d16dbc6110 100644 --- a/app/Exports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -1,6 +1,6 @@ Date: Wed, 23 Oct 2024 11:30:32 +0100 Subject: [PATCH 16/72] ZIP Exports: Added core logic for books/chapters --- app/Entities/Models/Chapter.php | 1 + .../ZipExports/Models/ZipExportBook.php | 53 ++++++++++++++++ .../ZipExports/Models/ZipExportChapter.php | 45 ++++++++++++++ .../ZipExports/Models/ZipExportPage.php | 12 ++++ app/Exports/ZipExports/ZipExportBuilder.php | 30 +++++++++ .../ZipExports/ZipExportReferences.php | 61 ++++++++++++++++--- dev/docs/portable-zip-file-format.md | 2 +- 7 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 app/Exports/ZipExports/Models/ZipExportBook.php create mode 100644 app/Exports/ZipExports/Models/ZipExportChapter.php diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index c926aaa647a..088d199da67 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -60,6 +60,7 @@ public function defaultTemplate(): BelongsTo /** * Get the visible pages in this chapter. + * @returns Collection */ public function getVisiblePages(): Collection { diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php new file mode 100644 index 00000000000..5a0c5806ba8 --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -0,0 +1,53 @@ +id = $model->id; + $instance->name = $model->name; + $instance->description_html = $model->descriptionHtml(); + + if ($model->cover) { + $instance->cover = $files->referenceForImage($model->cover); + } + + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + + $chapters = []; + $pages = []; + + $children = $model->getDirectVisibleChildren()->all(); + foreach ($children as $child) { + if ($child instanceof Chapter) { + $chapters[] = $child; + } else if ($child instanceof Page) { + $pages[] = $child; + } + } + + $instance->pages = ZipExportPage::fromModelArray($pages, $files); + $instance->chapters = ZipExportChapter::fromModelArray($chapters, $files); + + return $instance; + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php new file mode 100644 index 00000000000..cd5765f48bc --- /dev/null +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -0,0 +1,45 @@ +id = $model->id; + $instance->name = $model->name; + $instance->description_html = $model->descriptionHtml(); + $instance->priority = $model->priority; + $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); + + $pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all(); + $instance->pages = ZipExportPage::fromModelArray($pages, $files); + + return $instance; + } + + /** + * @param Chapter[] $chapterArray + * @return self[] + */ + public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Chapter $chapter) use ($files) { + return self::fromModel($chapter, $files); + }, $chapterArray)); + } +} diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index bae46ca8225..8075595f228 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -26,6 +26,7 @@ public static function fromModel(Page $model, ZipExportFiles $files): self $instance->id = $model->id; $instance->name = $model->name; $instance->html = (new PageContent($model))->render(); + $instance->priority = $model->priority; if (!empty($model->markdown)) { $instance->markdown = $model->markdown; @@ -36,4 +37,15 @@ public static function fromModel(Page $model, ZipExportFiles $files): self return $instance; } + + /** + * @param Page[] $pageArray + * @return self[] + */ + public static function fromModelArray(array $pageArray, ZipExportFiles $files): array + { + return array_values(array_map(function (Page $page) use ($files) { + return self::fromModel($page, $files); + }, $pageArray)); + } } diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php index 15edebea574..42fb03541c0 100644 --- a/app/Exports/ZipExports/ZipExportBuilder.php +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -2,8 +2,12 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exceptions\ZipExportException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; @@ -30,6 +34,32 @@ public function buildForPage(Page $page): string return $this->build(); } + /** + * @throws ZipExportException + */ + public function buildForChapter(Chapter $chapter): string + { + $exportChapter = ZipExportChapter::fromModel($chapter, $this->files); + $this->data['chapter'] = $exportChapter; + + $this->references->addChapter($exportChapter); + + return $this->build(); + } + + /** + * @throws ZipExportException + */ + public function buildForBook(Book $book): string + { + $exportBook = ZipExportBook::fromModel($book, $this->files); + $this->data['book'] = $exportBook; + + $this->references->addBook($exportBook); + + return $this->build(); + } + /** * @throws ZipExportException */ diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index c3565aaa383..1fce0fc972e 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -4,6 +4,8 @@ use BookStack\App\Model; use BookStack\Exports\ZipExports\Models\ZipExportAttachment; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; @@ -14,8 +16,10 @@ class ZipExportReferences { /** @var ZipExportPage[] */ protected array $pages = []; - protected array $books = []; + /** @var ZipExportChapter[] */ protected array $chapters = []; + /** @var ZipExportBook[] */ + protected array $books = []; /** @var ZipExportAttachment[] */ protected array $attachments = []; @@ -41,23 +45,64 @@ public function addPage(ZipExportPage $page): void } } + public function addChapter(ZipExportChapter $chapter): void + { + if ($chapter->id) { + $this->chapters[$chapter->id] = $chapter; + } + + foreach ($chapter->pages as $page) { + $this->addPage($page); + } + } + + public function addBook(ZipExportBook $book): void + { + if ($book->id) { + $this->chapters[$book->id] = $book; + } + + foreach ($book->pages as $page) { + $this->addPage($page); + } + + foreach ($book->chapters as $chapter) { + $this->addChapter($chapter); + } + } + public function buildReferences(ZipExportFiles $files): void { - // Parse page content first - foreach ($this->pages as $page) { - $handler = function (Model $model) use ($files, $page) { - return $this->handleModelReference($model, $page, $files); + $createHandler = function (ZipExportModel $zipModel) use ($files) { + return function (Model $model) use ($files, $zipModel) { + return $this->handleModelReference($model, $zipModel, $files); }; + }; + // Parse page content first + foreach ($this->pages as $page) { + $handler = $createHandler($page); $page->html = $this->parser->parse($page->html ?? '', $handler); if ($page->markdown) { $page->markdown = $this->parser->parse($page->markdown, $handler); } } -// dd('end'); - // TODO - Parse chapter desc html - // TODO - Parse book desc html + // Parse chapter description HTML + foreach ($this->chapters as $chapter) { + if ($chapter->description_html) { + $handler = $createHandler($chapter); + $chapter->description_html = $this->parser->parse($chapter->description_html, $handler); + } + } + + // Parse book description HTML + foreach ($this->books as $book) { + if ($book->description_html) { + $handler = $createHandler($book); + $book->description_html = $this->parser->parse($book->description_html, $handler); + } + } } protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 1ba5872018c..6cee7356d23 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -87,7 +87,7 @@ The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This i - `id` - Number, optional, original ID for the book from exported system. - `name` - String, required, name/title of the book. - `description_html` - String, optional, HTML description content. -- `cover` - String reference, options, reference to book cover image. +- `cover` - String reference, optional, reference to book cover image. - `chapters` - [Chapter](#chapter) array, optional, chapters within this book. - `pages` - [Page](#page) array, optional, direct child pages for this book. - `tags` - [Tag](#tag) array, optional, tags assigned to this book. From 484342f26adab723b8c4625d22a8901f5bfe79af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 23 Oct 2024 15:59:58 +0100 Subject: [PATCH 17/72] ZIP Exports: Added entity cross refs, Started export tests --- .../Controllers/BookExportController.php | 14 +++ .../Controllers/ChapterExportController.php | 13 +++ .../ZipExports/ZipExportReferences.php | 14 ++- routes/web.php | 1 + tests/Exports/ZipExportTest.php | 85 ++++++++++++++++++- tests/Exports/ZipResultData.php | 13 +++ 6 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 tests/Exports/ZipResultData.php diff --git a/app/Exports/Controllers/BookExportController.php b/app/Exports/Controllers/BookExportController.php index 36906b6ad7b..f726175a086 100644 --- a/app/Exports/Controllers/BookExportController.php +++ b/app/Exports/Controllers/BookExportController.php @@ -3,7 +3,9 @@ namespace BookStack\Exports\Controllers; use BookStack\Entities\Queries\BookQueries; +use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; @@ -63,4 +65,16 @@ public function markdown(string $bookSlug) return $this->download()->directly($textContent, $bookSlug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, ZipExportBuilder $builder) + { + $book = $this->queries->findVisibleBySlugOrFail($bookSlug); + $zip = $builder->buildForBook($book); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/Controllers/ChapterExportController.php b/app/Exports/Controllers/ChapterExportController.php index d85b90dcb41..0d7a5c0d195 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -5,6 +5,7 @@ use BookStack\Entities\Queries\ChapterQueries; use BookStack\Exceptions\NotFoundException; use BookStack\Exports\ExportFormatter; +use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Http\Controller; use Throwable; @@ -70,4 +71,16 @@ public function markdown(string $bookSlug, string $chapterSlug) return $this->download()->directly($chapterText, $chapterSlug . '.md'); } + + /** + * Export a book to a contained ZIP export file. + * @throws NotFoundException + */ + public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder) + { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + $zip = $builder->buildForChapter($chapter); + + return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip)); + } } diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index 1fce0fc972e..8b3a4b612fe 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -3,6 +3,9 @@ namespace BookStack\Exports\ZipExports; use BookStack\App\Model; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\Models\ZipExportAttachment; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; @@ -107,8 +110,6 @@ public function buildReferences(ZipExportFiles $files): void protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string { - // TODO - References to other entities - // Handle attachment references // No permission check needed here since they would only already exist in this // reference context if already allowed via their entity access. @@ -143,6 +144,15 @@ protected function handleModelReference(Model $model, ZipExportModel $exportMode return null; } + // Handle entity references + if ($model instanceof Book && isset($this->books[$model->id])) { + return "[[bsexport:book:{$model->id}]]"; + } else if ($model instanceof Chapter && isset($this->chapters[$model->id])) { + return "[[bsexport:chapter:{$model->id}]]"; + } else if ($model instanceof Page && isset($this->pages[$model->id])) { + return "[[bsexport:page:{$model->id}]]"; + } + return null; } } diff --git a/routes/web.php b/routes/web.php index 6ae70983d52..e6f3683c643 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,6 +132,7 @@ Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/html', [ExportControllers\ChapterExportController::class, 'html']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ExportControllers\ChapterExportController::class, 'markdown']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ExportControllers\ChapterExportController::class, 'plainText']); + Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/zip', [ExportControllers\ChapterExportController::class, 'zip']); Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [PermissionsController::class, 'updateForChapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [EntityControllers\ChapterController::class, 'showDelete']); diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index d8ce00be33f..536e23806f6 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -2,14 +2,95 @@ namespace Tests\Exports; -use BookStack\Entities\Models\Book; +use Illuminate\Support\Carbon; +use Illuminate\Testing\TestResponse; use Tests\TestCase; +use ZipArchive; class ZipExportTest extends TestCase { - public function test_page_export() + public function test_export_results_in_zip_format() + { + $page = $this->entities->page(); + $response = $this->asEditor()->get($page->getUrl("/export/zip")); + + $zipData = $response->streamedContent(); + $zipFile = tempnam(sys_get_temp_dir(), 'bstesta-'); + file_put_contents($zipFile, $zipData); + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::RDONLY); + + $this->assertNotFalse($zip->locateName('data.json')); + $this->assertNotFalse($zip->locateName('files/')); + + $data = json_decode($zip->getFromName('data.json'), true); + $this->assertIsArray($data); + $this->assertGreaterThan(0, count($data)); + + $zip->close(); + unlink($zipFile); + } + + public function test_export_metadata() { $page = $this->entities->page(); + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $this->assertEquals($page->id, $zip->data['page']['id'] ?? null); + $this->assertArrayNotHasKey('book', $zip->data); + $this->assertArrayNotHasKey('chapter', $zip->data); + + $now = time(); + $date = Carbon::parse($zip->data['exported_at'])->unix(); + $this->assertLessThan($now + 2, $date); + $this->assertGreaterThan($now - 2, $date); + + $version = trim(file_get_contents(base_path('version'))); + $this->assertEquals($version, $zip->data['instance']['version']); + + $instanceId = decrypt($zip->data['instance']['id_ciphertext']); + $this->assertEquals('bookstack', $instanceId); + } + + public function test_page_export() + { + // TODO + } + + public function test_book_export() + { + // TODO + } + + public function test_chapter_export() + { // TODO } + + protected function extractZipResponse(TestResponse $response): ZipResultData + { + $zipData = $response->streamedContent(); + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + file_put_contents($zipFile, $zipData); + $extractDir = tempnam(sys_get_temp_dir(), 'bstestextracted-'); + if (file_exists($extractDir)) { + unlink($extractDir); + } + mkdir($extractDir); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::RDONLY); + $zip->extractTo($extractDir); + + $dataJson = file_get_contents($extractDir . DIRECTORY_SEPARATOR . "data.json"); + $data = json_decode($dataJson, true); + + return new ZipResultData( + $zipFile, + $extractDir, + $data, + ); + } } diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php new file mode 100644 index 00000000000..b5cc2b4ca61 --- /dev/null +++ b/tests/Exports/ZipResultData.php @@ -0,0 +1,13 @@ + Date: Sun, 27 Oct 2024 14:33:43 +0000 Subject: [PATCH 18/72] ZIP Exports: Tested each type and model of export --- .../ZipExports/Models/ZipExportAttachment.php | 1 + .../ZipExports/ZipExportReferences.php | 2 +- app/Exports/ZipExports/ZipReferenceParser.php | 2 +- tests/Exports/ZipExportTest.php | 265 +++++++++++++++++- tests/Exports/ZipResultData.php | 9 + 5 files changed, 274 insertions(+), 5 deletions(-) diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 8c89ae11f14..283ffa751c9 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -18,6 +18,7 @@ public static function fromModel(Attachment $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; + $instance->order = $model->order; if ($model->external) { $instance->link = $model->path; diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index 8b3a4b612fe..c630c832b37 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -62,7 +62,7 @@ public function addChapter(ZipExportChapter $chapter): void public function addBook(ZipExportBook $book): void { if ($book->id) { - $this->chapters[$book->id] = $book; + $this->books[$book->id] = $book; } foreach ($book->pages as $page) { diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index 4d16dbc6110..da43d1b366b 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -38,7 +38,7 @@ public function __construct(EntityQueries $queries) public function parse(string $content, callable $handler): string { $escapedBase = preg_quote(url('/'), '/'); - $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#]/"; + $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#()]/"; $matches = []; preg_match_all($linkRegex, $content, $matches); diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 536e23806f6..ac07b33aef5 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -2,6 +2,11 @@ namespace Tests\Exports; +use BookStack\Activity\Models\Tag; +use BookStack\Entities\Repos\BookRepo; +use BookStack\Entities\Tools\PageContent; +use BookStack\Uploads\Attachment; +use BookStack\Uploads\Image; use Illuminate\Support\Carbon; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -55,17 +60,271 @@ public function test_export_metadata() public function test_page_export() { - // TODO + $page = $this->entities->page(); + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals([ + 'id' => $page->id, + 'name' => $page->name, + 'html' => (new PageContent($page))->render(), + 'priority' => $page->priority, + 'attachments' => [], + 'images' => [], + 'tags' => [], + ], $pageData); + } + + public function test_page_export_with_markdown() + { + $page = $this->entities->page(); + $markdown = "# My page\n\nwritten in markdown for export\n"; + $page->markdown = $markdown; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals($markdown, $pageData['markdown']); + $this->assertNotEmpty($pageData['html']); + } + + public function test_page_export_with_tags() + { + $page = $this->entities->page(); + $page->tags()->saveMany([ + new Tag(['name' => 'Exporty', 'value' => 'Content', 'order' => 1]), + new Tag(['name' => 'Another', 'value' => '', 'order' => 2]), + ]); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertEquals([ + [ + 'name' => 'Exporty', + 'value' => 'Content', + 'order' => 1, + ], + [ + 'name' => 'Another', + 'value' => '', + 'order' => 2, + ] + ], $pageData['tags']); + } + + public function test_page_export_with_images() + { + $this->asEditor(); + $page = $this->entities->page(); + $result = $this->files->uploadGalleryImageToPage($this, $page); + $displayThumb = $result['response']->thumbs->gallery ?? ''; + $page->html = '

    My image

    '; + $page->save(); + $image = Image::findOrFail($result['response']->id); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertCount(1, $pageData['images']); + $imageData = $pageData['images'][0]; + $this->assertEquals($image->id, $imageData['id']); + $this->assertEquals($image->name, $imageData['name']); + $this->assertEquals('gallery', $imageData['type']); + $this->assertNotEmpty($imageData['file']); + + $filePath = $zip->extractPath("files/{$imageData['file']}"); + $this->assertFileExists($filePath); + $this->assertEquals(file_get_contents(public_path($image->path)), file_get_contents($filePath)); + + $this->assertEquals('

    My image

    ', $pageData['html']); + } + + public function test_page_export_file_attachments() + { + $contents = 'My great attachment content!'; + + $page = $this->entities->page(); + $this->asAdmin(); + $attachment = $this->files->uploadAttachmentDataToPage($this, $page, 'PageAttachmentExport.txt', $contents, 'text/plain'); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertCount(1, $pageData['attachments']); + + $attachmentData = $pageData['attachments'][0]; + $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']); + $this->assertEquals($attachment->id, $attachmentData['id']); + $this->assertEquals(1, $attachmentData['order']); + $this->assertArrayNotHasKey('link', $attachmentData); + $this->assertNotEmpty($attachmentData['file']); + + $fileRef = $attachmentData['file']; + $filePath = $zip->extractPath("/files/$fileRef"); + $this->assertFileExists($filePath); + $this->assertEquals($contents, file_get_contents($filePath)); + } + + public function test_page_export_link_attachments() + { + $page = $this->entities->page(); + $this->asEditor(); + $attachment = Attachment::factory()->create([ + 'name' => 'My link attachment for export', + 'path' => 'https://example.com/cats', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + ]); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $pageData = $zip->data['page']; + $this->assertCount(1, $pageData['attachments']); + + $attachmentData = $pageData['attachments'][0]; + $this->assertEquals('My link attachment for export', $attachmentData['name']); + $this->assertEquals($attachment->id, $attachmentData['id']); + $this->assertEquals(1, $attachmentData['order']); + $this->assertEquals('https://example.com/cats', $attachmentData['link']); + $this->assertArrayNotHasKey('file', $attachmentData); } public function test_book_export() { - // TODO + $book = $this->entities->book(); + $book->tags()->saveMany(Tag::factory()->count(2)->make()); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertArrayHasKey('book', $zip->data); + + $bookData = $zip->data['book']; + $this->assertEquals($book->id, $bookData['id']); + $this->assertEquals($book->name, $bookData['name']); + $this->assertEquals($book->descriptionHtml(), $bookData['description_html']); + $this->assertCount(2, $bookData['tags']); + $this->assertCount($book->directPages()->count(), $bookData['pages']); + $this->assertCount($book->chapters()->count(), $bookData['chapters']); + $this->assertArrayNotHasKey('cover', $bookData); + } + + public function test_book_export_with_cover_image() + { + $book = $this->entities->book(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->files->uploadedImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + $coverImage = $book->cover()->first(); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + + $this->assertArrayHasKey('cover', $zip->data['book']); + $coverRef = $zip->data['book']['cover']; + $coverPath = $zip->extractPath("/files/$coverRef"); + $this->assertFileExists($coverPath); + $this->assertEquals(file_get_contents(public_path($coverImage->path)), file_get_contents($coverPath)); } public function test_chapter_export() { - // TODO + $chapter = $this->entities->chapter(); + $chapter->tags()->saveMany(Tag::factory()->count(2)->make()); + + $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertArrayHasKey('chapter', $zip->data); + + $chapterData = $zip->data['chapter']; + $this->assertEquals($chapter->id, $chapterData['id']); + $this->assertEquals($chapter->name, $chapterData['name']); + $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']); + $this->assertCount(2, $chapterData['tags']); + $this->assertEquals($chapter->priority, $chapterData['priority']); + $this->assertCount($chapter->pages()->count(), $chapterData['pages']); + } + + + public function test_cross_reference_links_are_converted() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + $book->description_html = '

    Link to chapter

    '; + $book->save(); + $chapter->description_html = '

    Link to page

    '; + $chapter->save(); + $page->html = '

    Link to book

    '; + $page->save(); + + $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $bookData = $zip->data['book']; + $chapterData = $bookData['chapters'][0]; + $pageData = $chapterData['pages'][0]; + + $this->assertStringContainsString('href="[[bsexport:chapter:' . $chapter->id . ']]"', $bookData['description_html']); + $this->assertStringContainsString('href="[[bsexport:page:' . $page->id . ']]#section2"', $chapterData['description_html']); + $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']); + } + + public function test_cross_reference_links_external_to_export_are_not_converted() + { + $page = $this->entities->page(); + $page->html = '

    Link to book

    '; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertStringContainsString('href="' . $page->book->getUrl() . '"', $pageData['html']); + } + + public function test_attachments_links_are_converted() + { + $page = $this->entities->page(); + $attachment = Attachment::factory()->create([ + 'name' => 'My link attachment for export reference', + 'path' => 'https://example.com/cats/ref', + 'external' => true, + 'uploaded_to' => $page->id, + 'order' => 1, + ]); + + $page->html = '

    id}") . '?open=true">Link to attachment

    '; + $page->save(); + + $zipResp = $this->asEditor()->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $this->assertStringContainsString('href="[[bsexport:attachment:' . $attachment->id . ']]?open=true"', $pageData['html']); + } + + public function test_links_in_markdown_are_parsed() + { + $chapter = $this->entities->chapterHasPages(); + $page = $chapter->pages()->first(); + + $page->markdown = "[Link to chapter]({$chapter->getUrl()})"; + $page->save(); + + $zipResp = $this->asEditor()->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['chapter']['pages'][0]; + + $this->assertStringContainsString("[Link to chapter]([[bsexport:chapter:{$chapter->id}]])", $pageData['markdown']); } protected function extractZipResponse(TestResponse $response): ZipResultData diff --git a/tests/Exports/ZipResultData.php b/tests/Exports/ZipResultData.php index b5cc2b4ca61..7725004c7be 100644 --- a/tests/Exports/ZipResultData.php +++ b/tests/Exports/ZipResultData.php @@ -10,4 +10,13 @@ public function __construct( public array $data, ) { } + + /** + * Build a path to a location the extracted content, using the given relative $path. + */ + public function extractPath(string $path): string + { + $relPath = implode(DIRECTORY_SEPARATOR, explode('/', $path)); + return $this->extractedDirPath . DIRECTORY_SEPARATOR . ltrim($relPath, DIRECTORY_SEPARATOR); + } } From f60671146379e76550ce81f7a8738f848ebc63de Mon Sep 17 00:00:00 2001 From: Rashad Date: Sun, 27 Oct 2024 22:50:20 +0530 Subject: [PATCH 19/72] respective book and chapter structure added. --- app/Api/ApiEntityListFormatter.php | 23 ++--- app/Search/SearchApiController.php | 63 ++---------- dev/api/requests/search-all.http | 2 +- dev/api/responses/search-all.json | 152 ++++++++++++++++------------- tests/Api/SearchApiTest.php | 109 --------------------- 5 files changed, 103 insertions(+), 246 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 2fd9b7c5507..7c2d09d4f3a 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -13,11 +13,6 @@ class ApiEntityListFormatter */ protected array $list = []; - /** - * Whether to include related titles in the response. - */ - protected bool $includeRelatedTitles = false; - /** * The fields to show in the formatted data. * Can be a plain string array item for a direct model field (If existing on model). @@ -79,20 +74,18 @@ public function withTags(): self /** * Enable the inclusion of related book and chapter titles in the response. */ - public function withRelatedTitles(): self + public function withRelatedData(): self { - $this->includeRelatedTitles = true; - - $this->withField('book_title', function (Entity $entity) { + $this->withField('book', function (Entity $entity) { if (method_exists($entity, 'book')) { - return $entity->book?->name; + return $entity->book()->select(['id', 'name', 'slug'])->first(); } return null; }); - $this->withField('chapter_title', function (Entity $entity) { + $this->withField('chapter', function (Entity $entity) { if ($entity instanceof Page && $entity->chapter_id) { - return optional($entity->getAttribute('chapter'))->name; + return $entity->chapter()->select(['id', 'name', 'slug'])->first(); } return null; }); @@ -106,9 +99,7 @@ public function withRelatedTitles(): self */ public function format(): array { - if ($this->includeRelatedTitles) { - $this->loadRelatedTitles(); - } + $this->loadRelatedData(); $results = []; @@ -122,7 +113,7 @@ public function format(): array /** * Eager load the related book and chapter data when needed. */ - protected function loadRelatedTitles(): void + protected function loadRelatedData(): void { $pages = collect($this->list)->filter(fn($item) => $item instanceof Page); diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 5072bd3b463..28a3b53e6c3 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -17,20 +17,9 @@ class SearchApiController extends ApiController 'query' => ['required'], 'page' => ['integer', 'min:1'], 'count' => ['integer', 'min:1', 'max:100'], - 'include' => ['string', 'regex:/^[a-zA-Z,]*$/'], ], ]; - /** - * Valid include parameters and their corresponding formatter methods. - * These parameters allow for additional related data, like titles or tags, - * to be included in the search results when requested via the API. - */ - protected const VALID_INCLUDES = [ - 'titles' => 'withRelatedTitles', - 'tags' => 'withTags', - ]; - public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) { $this->searchRunner = $searchRunner; @@ -44,13 +33,6 @@ public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $ * for a full list of search term options. Results contain a 'type' property to distinguish * between: bookshelf, book, chapter & page. * - * This method now supports the 'include' parameter, which allows API clients to specify related - * fields (such as titles or tags) that should be included in the search results. - * - * The 'include' parameter is a comma-separated string. For example, adding `include=titles,tags` - * will include both titles and tags in the API response. If the parameter is not provided, only - * basic entity data will be returned. - * * The paging parameters and response format emulates a standard listing endpoint * but standard sorting and filtering cannot be done on this endpoint. If a count value * is provided this will only be taken as a suggestion. The results in the response @@ -63,49 +45,22 @@ public function all(Request $request) $options = SearchOptions::fromString($request->get('query') ?? ''); $page = intval($request->get('page', '0')) ?: 1; $count = min(intval($request->get('count', '0')) ?: 20, 100); - $includes = $this->parseIncludes($request->get('include', '')); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $this->resultsFormatter->format($results['results']->all(), $options); - $formatter = new ApiEntityListFormatter($results['results']->all()); - $formatter->withType(); // Always include type as it's essential for search results - - foreach ($includes as $include) { - if (isset(self::VALID_INCLUDES[$include])) { - $method = self::VALID_INCLUDES[$include]; - $formatter->$method(); - } - } - - $formatter->withField('preview_html', function (Entity $entity) { - return [ - 'name' => (string) $entity->getAttribute('preview_name'), - 'content' => (string) $entity->getAttribute('preview_content'), - ]; - }); + $data = (new ApiEntityListFormatter($results['results']->all())) + ->withType()->withTags()->withRelatedData() + ->withField('preview_html', function (Entity $entity) { + return [ + 'name' => (string) $entity->getAttribute('preview_name'), + 'content' => (string) $entity->getAttribute('preview_content'), + ]; + })->format(); return response()->json([ - 'data' => $formatter->format(), + 'data' => $data, 'total' => $results['total'], ]); } - - /** - * Parse and validate the include parameter. - * - * @param string $includeString Comma-separated list of includes - * @return array - */ - protected function parseIncludes(string $includeString): array - { - if (empty($includeString)) { - return []; - } - - return array_filter( - explode(',', strtolower($includeString)), - fn($include) => isset (self::VALID_INCLUDES[$include]) - ); - } } diff --git a/dev/api/requests/search-all.http b/dev/api/requests/search-all.http index 7fa1a304e21..f9c17fa1696 100644 --- a/dev/api/requests/search-all.http +++ b/dev/api/requests/search-all.http @@ -1 +1 @@ -GET /api/search?query=cats+{created_by:me}&page=1&count=2&include=titles,tags +GET /api/search?query=cats+{created_by:me}&page=1&count=2 diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index bb45b795903..f60a12f750d 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -1,72 +1,92 @@ { - "data": [ - { - "id": 84, - "book_id": 1, - "slug": "a-chapter-for-cats", - "name": "A chapter for cats", - "created_at": "2021-11-14T15:57:35.000000Z", - "updated_at": "2021-11-14T15:57:35.000000Z", - "type": "chapter", - "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", - "book_title": "Cats", - "preview_html": { - "name": "A chapter for cats", - "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" - }, - "tags": [] - }, - { - "name": "The hows and whys of cats", - "id": 396, - "slug": "the-hows-and-whys-of-cats", - "book_id": 1, - "chapter_id": 75, - "draft": false, - "template": false, - "created_at": "2021-05-15T16:28:10.000000Z", - "updated_at": "2021-11-14T15:56:49.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", - "book_title": "Cats", - "chapter_title": "A chapter for cats", - "preview_html": { - "name": "The hows and whys of cats", - "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." - }, - "tags": [ + "data": [ { - "name": "Animal", - "value": "Cat", - "order": 0 + "id": 84, + "book_id": 1, + "slug": "a-chapter-for-cats", + "name": "A chapter for cats", + "created_at": "2021-11-14T15:57:35.000000Z", + "updated_at": "2021-11-14T15:57:35.000000Z", + "type": "chapter", + "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "preview_html": { + "name": "A chapter for cats", + "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" + }, + "tags": [] }, { - "name": "Category", - "value": "Top Content", - "order": 0 + "name": "The hows and whys of cats", + "id": 396, + "slug": "the-hows-and-whys-of-cats", + "book_id": 1, + "chapter_id": 75, + "draft": false, + "template": false, + "created_at": "2021-05-15T16:28:10.000000Z", + "updated_at": "2021-11-14T15:56:49.000000Z", + "type": "page", + "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 84, + "name": "A chapter for cats", + "slug": "a-chapter-for-cats" + }, + "preview_html": { + "name": "The hows and whys of cats", + "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." + }, + "tags": [ + { + "name": "Animal", + "value": "Cat", + "order": 0 + }, + { + "name": "Category", + "value": "Top Content", + "order": 0 + } + ] + }, + { + "name": "How advanced are cats?", + "id": 362, + "slug": "how-advanced-are-cats", + "book_id": 13, + "chapter_id": 73, + "draft": false, + "template": false, + "created_at": "2020-11-29T21:55:07.000000Z", + "updated_at": "2021-11-14T16:02:39.000000Z", + "type": "page", + "url": "https://example.com/books/my-book/page/how-advanced-are-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 84, + "name": "A chapter for cats", + "slug": "a-chapter-for-cats" + }, + "preview_html": { + "name": "How advanced are cats?", + "content": "cats are some of the most advanced animals in the world." + }, + "tags": [] } - ] - }, - { - "name": "How advanced are cats?", - "id": 362, - "slug": "how-advanced-are-cats", - "book_id": 13, - "chapter_id": 73, - "draft": false, - "template": false, - "created_at": "2020-11-29T21:55:07.000000Z", - "updated_at": "2021-11-14T16:02:39.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/how-advanced-are-cats", - "book_title": "Cats", - "chapter_title": "A chapter for cats", - "preview_html": { - "name": "How advanced are cats?", - "content": "cats are some of the most advanced animals in the world." - }, - "tags": [] - } - ], - "total": 3 + ], + "total": 3 } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index b80ed4530ba..3f2eb395c11 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -2,7 +2,6 @@ namespace Tests\Api; -use BookStack\Activity\Models\Tag; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; @@ -75,112 +74,4 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } - - public function test_all_endpoint_includes_book_and_chapter_titles_when_requested() - { - $this->actingAsApiEditor(); - - $book = $this->entities->book(); - $chapter = $this->entities->chapter(); - $page = $this->entities->newPage(); - - $book->name = 'My Test Book'; - $book->save(); - - $chapter->name = 'My Test Chapter'; - $chapter->book_id = $book->id; - $chapter->save(); - - $page->name = 'My Test Page With UniqueSearchTerm'; - $page->book_id = $book->id; - $page->chapter_id = $chapter->id; - $page->save(); - - $page->indexForSearch(); - - // Test without include parameter - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); - $resp->assertOk(); - $resp->assertDontSee('book_title'); - $resp->assertDontSee('chapter_title'); - - // Test with include parameter - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=titles'); - $resp->assertOk(); - $resp->assertJsonFragment([ - 'name' => 'My Test Page With UniqueSearchTerm', - 'book_title' => 'My Test Book', - 'chapter_title' => 'My Test Chapter', - 'type' => 'page' - ]); - } - - public function test_all_endpoint_validates_include_parameter() - { - $this->actingAsApiEditor(); - - // Test invalid include value - $resp = $this->getJson($this->baseEndpoint . '?query=test&include=invalid'); - $resp->assertOk(); - $resp->assertDontSee('book_title'); - - // Test SQL injection attempt - $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles;DROP TABLE users'); - $resp->assertStatus(422); - - // Test multiple includes - $resp = $this->getJson($this->baseEndpoint . '?query=test&include=titles,tags'); - $resp->assertOk(); - } - - public function test_all_endpoint_includes_tags_when_requested() - { - $this->actingAsApiEditor(); - - // Create a page and give it a unique name for search - $page = $this->entities->page(); - $page->name = 'Page With UniqueSearchTerm'; - $page->save(); - - // Save tags to the page using the existing saveTagsToEntity method - $tags = [ - ['name' => 'SampleTag', 'value' => 'SampleValue'] - ]; - app(\BookStack\Activity\TagRepo::class)->saveTagsToEntity($page, $tags); - - // Ensure the page is indexed for search - $page->indexForSearch(); - - // Test without the "tags" include - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm'); - $resp->assertOk(); - $resp->assertDontSee('tags'); - - // Test with the "tags" include - $resp = $this->getJson($this->baseEndpoint . '?query=UniqueSearchTerm&include=tags'); - $resp->assertOk(); - - // Assert that tags are included in the response - $resp->assertJsonFragment([ - 'name' => 'SampleTag', - 'value' => 'SampleValue', - ]); - - // Optionally: check the structure to match the tag order as well - $resp->assertJsonStructure([ - 'data' => [ - '*' => [ - 'tags' => [ - '*' => [ - 'name', - 'value', - 'order', - ], - ], - ], - ], - ]); - } - - } From 72d9ffd8b4a0680a858446c5f753db4f989f3989 Mon Sep 17 00:00:00 2001 From: Matthieu Leboeuf Date: Mon, 28 Oct 2024 22:14:30 +0100 Subject: [PATCH 20/72] Added support for concatenating multiple LDAP attributes in displayName --- app/Access/LdapService.php | 30 ++++++++++++++++++++++++++---- app/Config/services.php | 2 +- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index 365cb1db015..ef6d33f4db1 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -71,6 +71,28 @@ private function getUserWithAttributes(string $userName, array $attributes): ?ar return $users[0]; } + /** + * Calculate the display name. + */ + protected function getUserDisplayName(array $displayNameAttr, array $userDetails, string $defaultValue): string + { + $displayName = []; + foreach ($displayNameAttr as $dnAttr) { + $dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null); + if ($dnComponent !== null) { + $displayName[] = $dnComponent; + } + } + + if (count($displayName) == 0) { + $displayName = $defaultValue; + } else { + $displayName = implode(' ', $displayName); + } + + return $displayName; + } + /** * Get the details of a user from LDAP using the given username. * User found via configurable user filter. @@ -84,9 +106,9 @@ public function getUserDetails(string $userName): ?array $displayNameAttr = $this->config['display_name_attribute']; $thumbnailAttr = $this->config['thumbnail_attribute']; - $user = $this->getUserWithAttributes($userName, array_filter([ - 'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr, - ])); + $user = $this->getUserWithAttributes($userName, array_filter(array_merge($displayNameAttr, [ + 'cn', 'dn', $idAttr, $emailAttr, $thumbnailAttr, + ]))); if (is_null($user)) { return null; @@ -95,7 +117,7 @@ public function getUserDetails(string $userName): ?array $userCn = $this->getUserResponseProperty($user, 'cn', null); $formatted = [ 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), - 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn), + 'name' => $this->getUserDisplayName($displayNameAttr, $user, $userCn), 'dn' => $user['dn'], 'email' => $this->getUserResponseProperty($user, $emailAttr, null), 'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null, diff --git a/app/Config/services.php b/app/Config/services.php index d7345823150..4e27896870a 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -127,7 +127,7 @@ 'version' => env('LDAP_VERSION', false), 'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), - 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), + 'display_name_attribute' => explode('|', env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn')), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), From 87242ce6cb462bc933e63d80e514ae5096ee2b67 Mon Sep 17 00:00:00 2001 From: Matthieu Leboeuf Date: Mon, 28 Oct 2024 22:27:15 +0100 Subject: [PATCH 21/72] Adapt tests with displayName array --- tests/Auth/LdapTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index ef95bc2e8f4..27169a2becf 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -29,7 +29,7 @@ protected function setUp(): void 'auth.defaults.guard' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'services.ldap.email_attribute' => 'mail', - 'services.ldap.display_name_attribute' => 'cn', + 'services.ldap.display_name_attribute' => ['cn'], 'services.ldap.id_attribute' => 'uid', 'services.ldap.user_to_groups' => false, 'services.ldap.version' => '3', @@ -581,7 +581,7 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() public function test_login_uses_specified_display_name_attribute() { app('config')->set([ - 'services.ldap.display_name_attribute' => 'displayName', + 'services.ldap.display_name_attribute' => ['displayName'], ]); $this->commonLdapMocks(1, 1, 2, 4, 2); @@ -606,7 +606,7 @@ public function test_login_uses_specified_display_name_attribute() public function test_login_uses_default_display_name_attribute_if_specified_not_present() { app('config')->set([ - 'services.ldap.display_name_attribute' => 'displayName', + 'services.ldap.display_name_attribute' => ['displayName'], ]); $this->commonLdapMocks(1, 1, 2, 4, 2); From 4051d5b8037119b382c576042bc668b8f00eee14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Oct 2024 12:11:51 +0000 Subject: [PATCH 22/72] ZIP Exports: Added new import permission Also updated new route/view to new non-book-specific flow. Also fixed down migration of old export permissions migration. --- app/Exports/Controllers/ImportController.php | 24 ++++++++ ...8_28_161743_add_export_role_permission.php | 7 ++- ...0_29_114420_add_import_role_permission.php | 61 +++++++++++++++++++ lang/en/entities.php | 1 + lang/en/settings.php | 1 + resources/views/books/index.blade.php | 7 +++ resources/views/exports/import.blade.php | 34 +++++++++++ .../views/settings/roles/parts/form.blade.php | 1 + routes/web.php | 4 ++ 9 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 app/Exports/Controllers/ImportController.php create mode 100644 database/migrations/2024_10_29_114420_add_import_role_permission.php create mode 100644 resources/views/exports/import.blade.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php new file mode 100644 index 00000000000..acc803a0f0d --- /dev/null +++ b/app/Exports/Controllers/ImportController.php @@ -0,0 +1,24 @@ +middleware('can:content-import'); + } + + public function start(Request $request) + { + return view('exports.import'); + } + + public function upload(Request $request) + { + // TODO + } +} diff --git a/database/migrations/2021_08_28_161743_add_export_role_permission.php b/database/migrations/2021_08_28_161743_add_export_role_permission.php index 21f45aa0691..99416f9fcf6 100644 --- a/database/migrations/2021_08_28_161743_add_export_role_permission.php +++ b/database/migrations/2021_08_28_161743_add_export_role_permission.php @@ -11,8 +11,7 @@ */ public function up(): void { - // Create new templates-manage permission and assign to admin role - $roles = DB::table('roles')->get('id'); + // Create new content-export permission $permissionId = DB::table('role_permissions')->insertGetId([ 'name' => 'content-export', 'display_name' => 'Export Content', @@ -20,6 +19,7 @@ public function up(): void 'updated_at' => Carbon::now()->toDateTimeString(), ]); + $roles = DB::table('roles')->get('id'); $permissionRoles = $roles->map(function ($role) use ($permissionId) { return [ 'role_id' => $role->id, @@ -27,6 +27,7 @@ public function up(): void ]; })->values()->toArray(); + // Assign to all existing roles in the system DB::table('permission_role')->insert($permissionRoles); } @@ -40,6 +41,6 @@ public function down(): void ->where('name', '=', 'content-export')->first(); DB::table('permission_role')->where('permission_id', '=', $contentExportPermission->id)->delete(); - DB::table('role_permissions')->where('id', '=', 'content-export')->delete(); + DB::table('role_permissions')->where('id', '=', $contentExportPermission->id)->delete(); } }; diff --git a/database/migrations/2024_10_29_114420_add_import_role_permission.php b/database/migrations/2024_10_29_114420_add_import_role_permission.php new file mode 100644 index 00000000000..17bbe4cff26 --- /dev/null +++ b/database/migrations/2024_10_29_114420_add_import_role_permission.php @@ -0,0 +1,61 @@ +insertGetId([ + 'name' => 'content-import', + 'display_name' => 'Import Content', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + // Get existing admin-level role ids + $settingManagePermission = DB::table('role_permissions') + ->where('name', '=', 'settings-manage')->first(); + + if (!$settingManagePermission) { + return; + } + + $adminRoleIds = DB::table('permission_role') + ->where('permission_id', '=', $settingManagePermission->id) + ->pluck('role_id')->all(); + + // Assign the new permission to all existing admins + $newPermissionRoles = array_values(array_map(function ($roleId) use ($permissionId) { + return [ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + ]; + }, $adminRoleIds)); + + DB::table('permission_role')->insert($newPermissionRoles); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove content-import permission + $importPermission = DB::table('role_permissions') + ->where('name', '=', 'content-import')->first(); + + if (!$importPermission) { + return; + } + + DB::table('permission_role')->where('permission_id', '=', $importPermission->id)->delete(); + DB::table('role_permissions')->where('id', '=', $importPermission->id)->delete(); + } +}; diff --git a/lang/en/entities.php b/lang/en/entities.php index 7e5a708ef62..1a61b629a72 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -43,6 +43,7 @@ 'default_template' => 'Default Page Template', 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', + 'import' => 'Import', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/settings.php b/lang/en/settings.php index 5427cb9419e..c0b6b692a57 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -162,6 +162,7 @@ 'role_access_api' => 'Access system API', 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 0b407a8609a..418c0fea8d1 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -49,6 +49,13 @@ @icon('tag') {{ trans('entities.tags_view_tags') }} + + @if(userCan('content-import')) + + @icon('upload') + {{ trans('entities.import') }} + + @endif diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php new file mode 100644 index 00000000000..df8f705cb45 --- /dev/null +++ b/resources/views/exports/import.blade.php @@ -0,0 +1,34 @@ +@extends('layouts.simple') + +@section('body') + +
    + +
    +
    +
    +

    {{ trans('entities.import') }}

    +

    + TODO - Desc +{{-- {{ trans('entities.permissions_desc') }}--}} +

    +
    +
    +
    + {{ csrf_field() }} +
    +
    + @include('form.checkbox', ['name' => 'images', 'label' => 'Include Images']) + @include('form.checkbox', ['name' => 'attachments', 'label' => 'Include Attachments']) +
    +
    + +
    + {{ trans('common.cancel') }} + +
    +
    +
    +
    + +@stop diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 9fa76f2bfd7..a77b80e4c69 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -37,6 +37,7 @@
    @include('settings.roles.parts.checkbox', ['permission' => 'templates-manage', 'label' => trans('settings.role_manage_page_templates')])
    @include('settings.roles.parts.checkbox', ['permission' => 'access-api', 'label' => trans('settings.role_access_api')])
    @include('settings.roles.parts.checkbox', ['permission' => 'content-export', 'label' => trans('settings.role_export_content')])
    +
    @include('settings.roles.parts.checkbox', ['permission' => 'content-import', 'label' => trans('settings.role_import_content')])
    @include('settings.roles.parts.checkbox', ['permission' => 'editor-change', 'label' => trans('settings.role_editor_change')])
    @include('settings.roles.parts.checkbox', ['permission' => 'receive-notifications', 'label' => trans('settings.role_notifications')])
    diff --git a/routes/web.php b/routes/web.php index e6f3683c643..91aab13fecf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -206,6 +206,10 @@ // Watching Route::put('/watching/update', [ActivityControllers\WatchController::class, 'update']); + // Importing + Route::get('/import', [ExportControllers\ImportController::class, 'start']); + Route::post('/import', [ExportControllers\ImportController::class, 'upload']); + // Other Pages Route::get('/', [HomeController::class, 'index']); Route::get('/home', [HomeController::class, 'index']); From a56a28fbb7eaff40a639c2d06f56de255cd654ea Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 29 Oct 2024 14:21:32 +0000 Subject: [PATCH 23/72] ZIP Exports: Built out initial import view Added syles for non-custom, non-image file inputs. Started planning out back-end handling. --- app/Exports/Controllers/ImportController.php | 8 ++++- lang/en/entities.php | 1 + resources/sass/_forms.scss | 37 ++++++++++++++++++++ resources/views/exports/import.blade.php | 31 ++++++++-------- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index acc803a0f0d..9eefb097438 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -14,11 +14,17 @@ public function __construct() public function start(Request $request) { + // TODO - Show existing imports for user (or for all users if admin-level user) + return view('exports.import'); } public function upload(Request $request) { - // TODO + // TODO - Read existing ZIP upload and send through validator + // TODO - If invalid, return user with errors + // TODO - Upload to storage + // TODO - Store info/results from validator + // TODO - Send user to next import stage } } diff --git a/lang/en/entities.php b/lang/en/entities.php index 1a61b629a72..45ca4cf6b31 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -44,6 +44,7 @@ 'default_template_explain' => 'Assign a page template that will be used as the default content for all pages created within this item. Keep in mind this will only be used if the page creator has view access to the chosen template page.', 'default_template_select' => 'Select a template page', 'import' => 'Import', + 'import_validate' => 'Validate Import', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 67df4171499..1c679aaa0dd 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -545,6 +545,43 @@ input[type=color] { outline: 1px solid var(--color-primary); } +.custom-simple-file-input { + max-width: 100%; + border: 1px solid; + @include lightDark(border-color, #DDD, #666); + border-radius: 4px; + padding: $-s $-m; +} +.custom-simple-file-input::file-selector-button { + background-color: transparent; + text-decoration: none; + font-size: 0.8rem; + line-height: 1.4em; + padding: $-xs $-s; + border: 1px solid; + font-weight: 400; + outline: 0; + border-radius: 4px; + cursor: pointer; + margin-right: $-m; + @include lightDark(color, #666, #AAA); + @include lightDark(border-color, #CCC, #666); + &:hover, &:focus, &:active { + @include lightDark(color, #444, #BBB); + border: 1px solid #CCC; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); + background-color: #F2F2F2; + @include lightDark(background-color, #f8f8f8, #444); + filter: none; + } + &:active { + border-color: #BBB; + background-color: #DDD; + color: #666; + box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.1); + } +} + input.shortcut-input { width: auto; max-width: 120px; diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index df8f705cb45..b7030f11478 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -5,27 +5,30 @@
    -
    -
    -

    {{ trans('entities.import') }}

    -

    - TODO - Desc -{{-- {{ trans('entities.permissions_desc') }}--}} -

    -
    -
    +

    {{ trans('entities.import') }}

    {{ csrf_field() }} -
    -
    - @include('form.checkbox', ['name' => 'images', 'label' => 'Include Images']) - @include('form.checkbox', ['name' => 'attachments', 'label' => 'Include Attachments']) +
    +

    + Import content using a portable zip export from the same, or a different, instance. + Select a ZIP file to import then press "Validate Import" to proceed. + After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view. +

    +
    +
    + + +
    {{ trans('common.cancel') }} - +
    From 4b60c03caa5ff990bf935b4b6ede7d8d32f0e8c5 Mon Sep 17 00:00:00 2001 From: Zero Date: Tue, 29 Oct 2024 23:06:50 +0800 Subject: [PATCH 24/72] re-write Dockerfile --- dev/docker/Dockerfile | 52 +++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 50d528fafb8..5040298e052 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -1,34 +1,34 @@ FROM php:8.3-apache -ENV APACHE_DOCUMENT_ROOT /app/public -WORKDIR /app - -RUN < Date: Tue, 29 Oct 2024 23:07:15 +0800 Subject: [PATCH 25/72] fix deprecated syntax --- dev/docker/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 5040298e052..9f283c75544 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -30,5 +30,6 @@ RUN a2enmod rewrite && \ RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" && \ sed -i 's/memory_limit = 128M/memory_limit = 512M/g' "$PHP_INI_DIR/php.ini" -ENV APACHE_DOCUMENT_ROOT /app/public +ENV APACHE_DOCUMENT_ROOT="/app/public" + WORKDIR /app From b50b7b667d2266950baa56457f2ed8b7eeda273d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 30 Oct 2024 13:13:41 +0000 Subject: [PATCH 26/72] ZIP Exports: Started import validation --- .../ZipExportValidationException.php | 12 ++++ app/Exports/Controllers/ImportController.php | 6 ++ .../ZipExports/Models/ZipExportAttachment.php | 14 +++++ .../ZipExports/Models/ZipExportModel.php | 9 +++ .../ZipExports/Models/ZipExportTag.php | 12 ++++ app/Exports/ZipExports/ZipExportValidator.php | 63 +++++++++++++++++++ .../ZipExports/ZipFileReferenceRule.php | 26 ++++++++ .../ZipExports/ZipValidationHelper.php | 32 ++++++++++ lang/en/validation.php | 2 + resources/views/exports/import.blade.php | 2 +- 10 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/ZipExportValidationException.php create mode 100644 app/Exports/ZipExports/ZipExportValidator.php create mode 100644 app/Exports/ZipExports/ZipFileReferenceRule.php create mode 100644 app/Exports/ZipExports/ZipValidationHelper.php diff --git a/app/Exceptions/ZipExportValidationException.php b/app/Exceptions/ZipExportValidationException.php new file mode 100644 index 00000000000..2ed567d6343 --- /dev/null +++ b/app/Exceptions/ZipExportValidationException.php @@ -0,0 +1,12 @@ +validate($request, [ + 'file' => ['required', 'file'] + ]); + + $file = $request->file('file'); + $file->getRealPath(); // TODO - Read existing ZIP upload and send through validator // TODO - If invalid, return user with errors // TODO - Upload to storage diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 283ffa751c9..ab1f5ab7559 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Attachment; class ZipExportAttachment extends ZipExportModel @@ -35,4 +36,17 @@ public static function fromModelArray(array $attachmentArray, ZipExportFiles $fi return self::fromModel($attachment, $files); }, $attachmentArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'order' => ['nullable', 'integer'], + 'link' => ['required_without:file', 'nullable', 'string'], + 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], + ]; + + return $context->validateArray($data, $rules); + } } diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php index 8d0c0a4370b..4d66f010f86 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -2,6 +2,7 @@ namespace BookStack\Exports\ZipExports\Models; +use BookStack\Exports\ZipExports\ZipValidationHelper; use JsonSerializable; abstract class ZipExportModel implements JsonSerializable @@ -17,4 +18,12 @@ public function jsonSerialize(): array $publicProps = get_object_vars(...)->__invoke($this); return array_filter($publicProps, fn ($value) => $value !== null); } + + /** + * Validate the given array of data intended for this model. + * Return an array of validation errors messages. + * Child items can be considered in the validation result by returning a keyed + * item in the array for its own validation messages. + */ + abstract public static function validate(ZipValidationHelper $context, array $data): array; } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index d4e3c4290b3..ad17d5a33c6 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -3,6 +3,7 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Activity\Models\Tag; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportTag extends ZipExportModel { @@ -24,4 +25,15 @@ public static function fromModelArray(array $tagArray): array { return array_values(array_map(self::fromModel(...), $tagArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'name' => ['required', 'string', 'min:1'], + 'value' => ['nullable', 'string'], + 'order' => ['nullable', 'integer'], + ]; + + return $context->validateArray($data, $rules); + } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php new file mode 100644 index 00000000000..5ad9272de43 --- /dev/null +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -0,0 +1,63 @@ +zipPath) || !is_readable($this->zipPath)) { + $this->throwErrors("Could not read ZIP file"); + } + + // Validate file is valid zip + $zip = new \ZipArchive(); + $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); + if ($opened !== true) { + $this->throwErrors("Could not read ZIP file"); + } + + // Validate json data exists, including metadata + $jsonData = $zip->getFromName('data.json') ?: ''; + $importData = json_decode($jsonData, true); + if (!$importData) { + $this->throwErrors("Could not decode ZIP data.json content"); + } + + if (isset($importData['book'])) { + // TODO - Validate book + } else if (isset($importData['chapter'])) { + // TODO - Validate chapter + } else if (isset($importData['page'])) { + // TODO - Validate page + } else { + $this->throwErrors("ZIP file has no book, chapter or page data"); + } + } + + /** + * @throws ZipExportValidationException + */ + protected function throwErrors(...$errorsToAdd): never + { + array_push($this->errors, ...$errorsToAdd); + throw new ZipExportValidationException($this->errors); + } +} diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php new file mode 100644 index 00000000000..4f942e0e789 --- /dev/null +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -0,0 +1,26 @@ +context->zipFileExists($value)) { + $fail('validation.zip_file')->translate(); + } + } +} diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php new file mode 100644 index 00000000000..dd41e6f8b25 --- /dev/null +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -0,0 +1,32 @@ +validationFactory = app(Factory::class); + } + + public function validateArray(array $data, array $rules): array + { + return $this->validationFactory->make($data, $rules)->errors()->messages(); + } + + public function zipFileExists(string $name): bool + { + return $this->zip->statName("files/{$name}") !== false; + } + + public function fileReferenceRule(): ZipFileReferenceRule + { + return new ZipFileReferenceRule($this); + } +} diff --git a/lang/en/validation.php b/lang/en/validation.php index 2a676c7c4cc..6971edc023a 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,6 +105,8 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index b7030f11478..9fe596d8888 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -10,7 +10,7 @@ {{ csrf_field() }}

    - Import content using a portable zip export from the same, or a different, instance. + Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view.

    From c4ec50d437e52ccd831b6fb2e43baa5cf255fd1a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 30 Oct 2024 15:26:23 +0000 Subject: [PATCH 27/72] ZIP Exports: Got zip format validation functionally complete --- .../ZipExportValidationException.php | 12 ----- app/Exports/Controllers/ImportController.php | 9 +++- .../ZipExports/Models/ZipExportAttachment.php | 2 +- .../ZipExports/Models/ZipExportBook.php | 21 ++++++++ .../ZipExports/Models/ZipExportChapter.php | 19 +++++++ .../ZipExports/Models/ZipExportImage.php | 14 +++++ .../ZipExports/Models/ZipExportPage.php | 22 ++++++++ .../ZipExports/Models/ZipExportTag.php | 2 +- app/Exports/ZipExports/ZipExportValidator.php | 53 +++++++++++-------- .../ZipExports/ZipValidationHelper.php | 31 ++++++++++- lang/en/validation.php | 3 +- resources/views/exports/import.blade.php | 3 +- 12 files changed, 149 insertions(+), 42 deletions(-) delete mode 100644 app/Exceptions/ZipExportValidationException.php diff --git a/app/Exceptions/ZipExportValidationException.php b/app/Exceptions/ZipExportValidationException.php deleted file mode 100644 index 2ed567d6343..00000000000 --- a/app/Exceptions/ZipExportValidationException.php +++ /dev/null @@ -1,12 +0,0 @@ -file('file'); - $file->getRealPath(); + $zipPath = $file->getRealPath(); + + $errors = (new ZipExportValidator($zipPath))->validate(); + if ($errors) { + dd($errors); + } + dd('passed'); // TODO - Read existing ZIP upload and send through validator // TODO - If invalid, return user with errors // TODO - Upload to storage diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index ab1f5ab7559..e586b91b0ee 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -47,6 +47,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; - return $context->validateArray($data, $rules); + return $context->validateData($data, $rules); } } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 5a0c5806ba8..7e1f2d8106e 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportBook extends ZipExportModel { @@ -50,4 +51,24 @@ public static function fromModel(Book $model, ZipExportFiles $files): self return $instance; } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'description_html' => ['nullable', 'string'], + 'cover' => ['nullable', 'string', $context->fileReferenceRule()], + 'tags' => ['array'], + 'pages' => ['array'], + 'chapters' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class); + $errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class); + + return $errors; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index cd5765f48bc..03df31b7078 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportChapter extends ZipExportModel { @@ -42,4 +43,22 @@ public static function fromModelArray(array $chapterArray, ZipExportFiles $files return self::fromModel($chapter, $files); }, $chapterArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'description_html' => ['nullable', 'string'], + 'priority' => ['nullable', 'int'], + 'tags' => ['array'], + 'pages' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + $errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class); + + return $errors; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 05d828734a0..3388c66df36 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -3,7 +3,9 @@ namespace BookStack\Exports\ZipExports\Models; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Uploads\Image; +use Illuminate\Validation\Rule; class ZipExportImage extends ZipExportModel { @@ -22,4 +24,16 @@ public static function fromModel(Image $model, ZipExportFiles $files): self return $instance; } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'file' => ['required', 'string', $context->fileReferenceRule()], + 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], + ]; + + return $context->validateData($data, $rules); + } } diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 8075595f228..2c8b9a88abd 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\PageContent; use BookStack\Exports\ZipExports\ZipExportFiles; +use BookStack\Exports\ZipExports\ZipValidationHelper; class ZipExportPage extends ZipExportModel { @@ -48,4 +49,25 @@ public static function fromModelArray(array $pageArray, ZipExportFiles $files): return self::fromModel($page, $files); }, $pageArray)); } + + public static function validate(ZipValidationHelper $context, array $data): array + { + $rules = [ + 'id' => ['nullable', 'int'], + 'name' => ['required', 'string', 'min:1'], + 'html' => ['nullable', 'string'], + 'markdown' => ['nullable', 'string'], + 'priority' => ['nullable', 'int'], + 'attachments' => ['array'], + 'images' => ['array'], + 'tags' => ['array'], + ]; + + $errors = $context->validateData($data, $rules); + $errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class); + $errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class); + $errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class); + + return $errors; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index ad17d5a33c6..99abb811c06 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -34,6 +34,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra 'order' => ['nullable', 'integer'], ]; - return $context->validateArray($data, $rules); + return $context->validateData($data, $rules); } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index 5ad9272de43..e56394acaeb 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -2,62 +2,69 @@ namespace BookStack\Exports\ZipExports; -use BookStack\Exceptions\ZipExportValidationException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; class ZipExportValidator { - protected array $errors = []; - public function __construct( protected string $zipPath, ) { } - /** - * @throws ZipExportValidationException - */ - public function validate() + public function validate(): array { - // TODO - Return type - // TODO - extract messages to translations? - // Validate file exists if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - $this->throwErrors("Could not read ZIP file"); + return ['format' => "Could not read ZIP file"]; } // Validate file is valid zip $zip = new \ZipArchive(); $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); if ($opened !== true) { - $this->throwErrors("Could not read ZIP file"); + return ['format' => "Could not read ZIP file"]; } // Validate json data exists, including metadata $jsonData = $zip->getFromName('data.json') ?: ''; $importData = json_decode($jsonData, true); if (!$importData) { - $this->throwErrors("Could not decode ZIP data.json content"); + return ['format' => "Could not find and decode ZIP data.json content"]; } + $helper = new ZipValidationHelper($zip); + if (isset($importData['book'])) { - // TODO - Validate book + $modelErrors = ZipExportBook::validate($helper, $importData['book']); + $keyPrefix = 'book'; } else if (isset($importData['chapter'])) { - // TODO - Validate chapter + $modelErrors = ZipExportChapter::validate($helper, $importData['chapter']); + $keyPrefix = 'chapter'; } else if (isset($importData['page'])) { - // TODO - Validate page + $modelErrors = ZipExportPage::validate($helper, $importData['page']); + $keyPrefix = 'page'; } else { - $this->throwErrors("ZIP file has no book, chapter or page data"); + return ['format' => "ZIP file has no book, chapter or page data"]; } + + return $this->flattenModelErrors($modelErrors, $keyPrefix); } - /** - * @throws ZipExportValidationException - */ - protected function throwErrors(...$errorsToAdd): never + protected function flattenModelErrors(array $errors, string $keyPrefix): array { - array_push($this->errors, ...$errorsToAdd); - throw new ZipExportValidationException($this->errors); + $flattened = []; + + foreach ($errors as $key => $error) { + if (is_array($error)) { + $flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key)); + } else { + $flattened[$keyPrefix . '.' . $key] = $error; + } + } + + return $flattened; } } diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index dd41e6f8b25..8c285deaf5d 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -2,6 +2,7 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Exports\ZipExports\Models\ZipExportModel; use Illuminate\Validation\Factory; use ZipArchive; @@ -15,9 +16,15 @@ public function __construct( $this->validationFactory = app(Factory::class); } - public function validateArray(array $data, array $rules): array + public function validateData(array $data, array $rules): array { - return $this->validationFactory->make($data, $rules)->errors()->messages(); + $messages = $this->validationFactory->make($data, $rules)->errors()->messages(); + + foreach ($messages as $key => $message) { + $messages[$key] = implode("\n", $message); + } + + return $messages; } public function zipFileExists(string $name): bool @@ -29,4 +36,24 @@ public function fileReferenceRule(): ZipFileReferenceRule { return new ZipFileReferenceRule($this); } + + /** + * Validate an array of relation data arrays that are expected + * to be for the given ZipExportModel. + * @param class-string $model + */ + public function validateRelations(array $relations, string $model): array + { + $results = []; + + foreach ($relations as $key => $relationData) { + if (is_array($relationData)) { + $results[$key] = $model::validate($this, $relationData); + } else { + $results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])]; + } + } + + return $results; + } } diff --git a/lang/en/validation.php b/lang/en/validation.php index 6971edc023a..9cf5d78b6f6 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,7 +105,8 @@ 'url' => 'The :attribute format is invalid.', 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', - 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_model_expected' => 'Data object expected but ":type" found', // Custom validation lines 'custom' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index 9fe596d8888..15f33e6b7c1 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -6,7 +6,7 @@

    {{ trans('entities.import') }}

    -
    + {{ csrf_field() }}

    @@ -22,6 +22,7 @@ name="file" id="file" class="custom-simple-file-input"> + @include('form.errors', ['name' => 'file'])

    From 259aa829d42b1cd93011d5b8b531c15804741cb5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 14:51:04 +0000 Subject: [PATCH 28/72] ZIP Imports: Added validation message display, added testing Testing covers main UI access, and main non-successfull import actions. Started planning stored import model. Extracted some text to language files. --- app/Exports/Controllers/ImportController.php | 20 ++- app/Exports/ZipExports/ZipExportValidator.php | 9 +- lang/en/entities.php | 3 + lang/en/errors.php | 5 + lang/en/validation.php | 2 +- resources/views/exports/import.blade.php | 17 ++- tests/Exports/ZipImportTest.php | 124 ++++++++++++++++++ 7 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 tests/Exports/ZipImportTest.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 5885f7991cd..323ecef268f 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -17,7 +17,9 @@ public function start(Request $request) { // TODO - Show existing imports for user (or for all users if admin-level user) - return view('exports.import'); + return view('exports.import', [ + 'zipErrors' => session()->pull('validation_errors') ?? [], + ]); } public function upload(Request $request) @@ -31,13 +33,21 @@ public function upload(Request $request) $errors = (new ZipExportValidator($zipPath))->validate(); if ($errors) { - dd($errors); + session()->flash('validation_errors', $errors); + return redirect('/import'); } + dd('passed'); - // TODO - Read existing ZIP upload and send through validator - // TODO - If invalid, return user with errors // TODO - Upload to storage - // TODO - Store info/results from validator + // TODO - Store info/results for display: + // - zip_path + // - name (From name of thing being imported) + // - size + // - book_count + // - chapter_count + // - page_count + // - created_by + // - created_at/updated_at // TODO - Send user to next import stage } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index e56394acaeb..dd56f3e70a8 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -18,21 +18,21 @@ public function validate(): array { // Validate file exists if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - return ['format' => "Could not read ZIP file"]; + return ['format' => trans('errors.import_zip_cant_read')]; } // Validate file is valid zip $zip = new \ZipArchive(); $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); if ($opened !== true) { - return ['format' => "Could not read ZIP file"]; + return ['format' => trans('errors.import_zip_cant_read')]; } // Validate json data exists, including metadata $jsonData = $zip->getFromName('data.json') ?: ''; $importData = json_decode($jsonData, true); if (!$importData) { - return ['format' => "Could not find and decode ZIP data.json content"]; + return ['format' => trans('errors.import_zip_cant_decode_data')]; } $helper = new ZipValidationHelper($zip); @@ -47,9 +47,10 @@ public function validate(): array $modelErrors = ZipExportPage::validate($helper, $importData['page']); $keyPrefix = 'page'; } else { - return ['format' => "ZIP file has no book, chapter or page data"]; + return ['format' => trans('errors.import_zip_no_data')]; } + return $this->flattenModelErrors($modelErrors, $keyPrefix); } diff --git a/lang/en/entities.php b/lang/en/entities.php index 45ca4cf6b31..10614733533 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -45,6 +45,9 @@ 'default_template_select' => 'Select a template page', 'import' => 'Import', 'import_validate' => 'Validate Import', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_zip_select' => 'Select ZIP file to upload', + 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/errors.php b/lang/en/errors.php index 9c40aa9ed33..3f2f303311e 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -105,6 +105,11 @@ 'app_down' => ':appName is down right now', 'back_soon' => 'It will be back up soon.', + // Import + 'import_zip_cant_read' => 'Could not read ZIP file.', + 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', + 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + // API errors 'api_no_authorization_found' => 'No authorization token found on the request', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/en/validation.php b/lang/en/validation.php index 9cf5d78b6f6..bc01ac47b94 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,7 +106,7 @@ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', - 'zip_model_expected' => 'Data object expected but ":type" found', + 'zip_model_expected' => 'Data object expected but ":type" found.', // Custom validation lines 'custom' => [ diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index 15f33e6b7c1..c4d7c881845 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -9,14 +9,10 @@ {{ csrf_field() }}
    -

    - Import books, chapters & pages using a portable zip export from the same, or a different, instance. - Select a ZIP file to import then press "Validate Import" to proceed. - After the file has been uploaded and validated you'll be able to configure & confirm the import in the next view. -

    +

    {{ trans('entities.import_desc') }}

    - +
    + @if(count($zipErrors) > 0) +

    {{ trans('entities.import_zip_validation_errors') }}

    +
      + @foreach($zipErrors as $key => $error) +
    • [{{ $key }}]: {{ $error }}
    • + @endforeach +
    + @endif +
    {{ trans('common.cancel') }} diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php new file mode 100644 index 00000000000..c9d255b1e97 --- /dev/null +++ b/tests/Exports/ZipImportTest.php @@ -0,0 +1,124 @@ +asAdmin()->get('/import'); + $resp->assertSee('Import'); + $this->withHtml($resp)->assertElementExists('form input[type="file"][name="file"]'); + } + + public function test_permissions_needed_for_import_page() + { + $user = $this->users->viewer(); + $this->actingAs($user); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertLinkNotExists(url('/import')); + $resp = $this->get('/import'); + $resp->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->get('/books'); + $this->withHtml($resp)->assertLinkExists(url('/import')); + $resp = $this->get('/import'); + $resp->assertOk(); + $resp->assertSeeText('Select ZIP file to upload'); + } + + public function test_zip_read_errors_are_shown_on_validation() + { + $invalidUpload = $this->files->uploadedImage('image.zip'); + + $this->asAdmin(); + $resp = $this->runImportFromFile($invalidUpload); + $resp->assertRedirect('/import'); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('Could not read ZIP file'); + } + + public function test_error_shown_if_missing_data() + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('beans', 'cat'); + $zip->close(); + + $this->asAdmin(); + $upload = new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + $resp = $this->runImportFromFile($upload); + $resp->assertRedirect('/import'); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('Could not find and decode ZIP data.json content.'); + } + + public function test_error_shown_if_no_importable_key() + { + $this->asAdmin(); + $resp = $this->runImportFromFile($this->zipUploadFromData([ + 'instance' => [] + ])); + + $resp->assertRedirect('/import'); + $resp = $this->followRedirects($resp); + $resp->assertSeeText('ZIP file data has no expected book, chapter or page content.'); + } + + public function test_zip_data_validation_messages_shown() + { + $this->asAdmin(); + $resp = $this->runImportFromFile($this->zipUploadFromData([ + 'book' => [ + 'id' => 4, + 'pages' => [ + 'cat', + [ + 'name' => 'My inner page', + 'tags' => [ + [ + 'value' => 5 + ] + ], + ] + ], + ] + ])); + + $resp->assertRedirect('/import'); + $resp = $this->followRedirects($resp); + + $resp->assertSeeText('[book.name]: The name field is required.'); + $resp->assertSeeText('[book.pages.0.0]: Data object expected but "string" found.'); + $resp->assertSeeText('[book.pages.1.tags.0.name]: The name field is required.'); + $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.'); + } + + protected function runImportFromFile(UploadedFile $file): TestResponse + { + return $this->call('POST', '/import', [], [], ['file' => $file]); + } + + protected function zipUploadFromData(array $data): UploadedFile + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('data.json', json_encode($data)); + $zip->close(); + + return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + } +} From 74fce9640ef39a743bdb5a997724465c7c2b764c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 17:17:34 +0000 Subject: [PATCH 29/72] ZIP Import: Added model+migration, and reader class --- app/Exports/Controllers/ImportController.php | 24 +++-- app/Exports/Import.php | 41 +++++++ app/Exports/ZipExports/ZipExportReader.php | 102 ++++++++++++++++++ app/Exports/ZipExports/ZipExportValidator.php | 26 ++--- .../ZipExports/ZipFileReferenceRule.php | 2 +- .../ZipExports/ZipValidationHelper.php | 8 +- database/factories/Exports/ImportFactory.php | 32 ++++++ ...2024_11_02_160700_create_imports_table.php | 34 ++++++ 8 files changed, 234 insertions(+), 35 deletions(-) create mode 100644 app/Exports/Import.php create mode 100644 app/Exports/ZipExports/ZipExportReader.php create mode 100644 database/factories/Exports/ImportFactory.php create mode 100644 database/migrations/2024_11_02_160700_create_imports_table.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 323ecef268f..bbf0ff57d8c 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -2,6 +2,8 @@ namespace BookStack\Exports\Controllers; +use BookStack\Exports\Import; +use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Http\Controller; use Illuminate\Http\Request; @@ -37,17 +39,23 @@ public function upload(Request $request) return redirect('/import'); } + $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $import = new Import(); + $import->name = $zipEntityInfo['name']; + $import->book_count = $zipEntityInfo['book_count']; + $import->chapter_count = $zipEntityInfo['chapter_count']; + $import->page_count = $zipEntityInfo['page_count']; + $import->created_by = user()->id; + $import->size = filesize($zipPath); + // TODO - Set path + // TODO - Save + + // TODO - Split out attachment service to separate out core filesystem/disk stuff + // To reuse for import handling + dd('passed'); // TODO - Upload to storage // TODO - Store info/results for display: - // - zip_path - // - name (From name of thing being imported) - // - size - // - book_count - // - chapter_count - // - page_count - // - created_by - // - created_at/updated_at // TODO - Send user to next import stage } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php new file mode 100644 index 00000000000..c3ac3d52924 --- /dev/null +++ b/app/Exports/Import.php @@ -0,0 +1,41 @@ +book_count === 1) { + return self::TYPE_BOOK; + } elseif ($this->chapter_count === 1) { + return self::TYPE_CHAPTER; + } + + return self::TYPE_PAGE; + } +} diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php new file mode 100644 index 00000000000..7187a18897d --- /dev/null +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -0,0 +1,102 @@ +zip = new ZipArchive(); + } + + /** + * @throws ZipExportException + */ + protected function open(): void + { + if ($this->open) { + return; + } + + // Validate file exists + if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { + throw new ZipExportException(trans('errors.import_zip_cant_read')); + } + + // Validate file is valid zip + $opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY); + if ($opened !== true) { + throw new ZipExportException(trans('errors.import_zip_cant_read')); + } + + $this->open = true; + } + + public function close(): void + { + if ($this->open) { + $this->zip->close(); + $this->open = false; + } + } + + /** + * @throws ZipExportException + */ + public function readData(): array + { + $this->open(); + + // Validate json data exists, including metadata + $jsonData = $this->zip->getFromName('data.json') ?: ''; + $importData = json_decode($jsonData, true); + if (!$importData) { + throw new ZipExportException(trans('errors.import_zip_cant_decode_data')); + } + + return $importData; + } + + public function fileExists(string $fileName): bool + { + return $this->zip->statName("files/{$fileName}") !== false; + } + + /** + * @throws ZipExportException + * @returns array{name: string, book_count: int, chapter_count: int, page_count: int} + */ + public function getEntityInfo(): array + { + $data = $this->readData(); + $info = ['name' => '', 'book_count' => 0, 'chapter_count' => 0, 'page_count' => 0]; + + if (isset($data['book'])) { + $info['name'] = $data['book']['name'] ?? ''; + $info['book_count']++; + $chapters = $data['book']['chapters'] ?? []; + $pages = $data['book']['pages'] ?? []; + $info['chapter_count'] += count($chapters); + $info['page_count'] += count($pages); + foreach ($chapters as $chapter) { + $info['page_count'] += count($chapter['pages'] ?? []); + } + } elseif (isset($data['chapter'])) { + $info['name'] = $data['chapter']['name'] ?? ''; + $info['chapter_count']++; + $info['page_count'] += count($data['chapter']['pages'] ?? []); + } elseif (isset($data['page'])) { + $info['name'] = $data['page']['name'] ?? ''; + $info['page_count']++; + } + + return $info; + } +} diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index dd56f3e70a8..e476998c216 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -2,10 +2,10 @@ namespace BookStack\Exports\ZipExports; +use BookStack\Exceptions\ZipExportException; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportPage; -use ZipArchive; class ZipExportValidator { @@ -16,26 +16,14 @@ public function __construct( public function validate(): array { - // Validate file exists - if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) { - return ['format' => trans('errors.import_zip_cant_read')]; + $reader = new ZipExportReader($this->zipPath); + try { + $importData = $reader->readData(); + } catch (ZipExportException $exception) { + return ['format' => $exception->getMessage()]; } - // Validate file is valid zip - $zip = new \ZipArchive(); - $opened = $zip->open($this->zipPath, ZipArchive::RDONLY); - if ($opened !== true) { - return ['format' => trans('errors.import_zip_cant_read')]; - } - - // Validate json data exists, including metadata - $jsonData = $zip->getFromName('data.json') ?: ''; - $importData = json_decode($jsonData, true); - if (!$importData) { - return ['format' => trans('errors.import_zip_cant_decode_data')]; - } - - $helper = new ZipValidationHelper($zip); + $helper = new ZipValidationHelper($reader); if (isset($importData['book'])) { $modelErrors = ZipExportBook::validate($helper, $importData['book']); diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index 4f942e0e789..bcd3c39acf0 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -19,7 +19,7 @@ public function __construct( */ public function validate(string $attribute, mixed $value, Closure $fail): void { - if (!$this->context->zipFileExists($value)) { + if (!$this->context->zipReader->fileExists($value)) { $fail('validation.zip_file')->translate(); } } diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index 8c285deaf5d..55c86b03b5b 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -4,14 +4,13 @@ use BookStack\Exports\ZipExports\Models\ZipExportModel; use Illuminate\Validation\Factory; -use ZipArchive; class ZipValidationHelper { protected Factory $validationFactory; public function __construct( - protected ZipArchive $zip, + public ZipExportReader $zipReader, ) { $this->validationFactory = app(Factory::class); } @@ -27,11 +26,6 @@ public function validateData(array $data, array $rules): array return $messages; } - public function zipFileExists(string $name): bool - { - return $this->zip->statName("files/{$name}") !== false; - } - public function fileReferenceRule(): ZipFileReferenceRule { return new ZipFileReferenceRule($this); diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php new file mode 100644 index 00000000000..55378d5832e --- /dev/null +++ b/database/factories/Exports/ImportFactory.php @@ -0,0 +1,32 @@ + 'uploads/imports/' . Str::random(10) . '.zip', + 'name' => $this->faker->words(3, true), + 'book_count' => 1, + 'chapter_count' => 5, + 'page_count' => 15, + 'created_at' => User::factory(), + ]; + } +} diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php new file mode 100644 index 00000000000..ed188226981 --- /dev/null +++ b/database/migrations/2024_11_02_160700_create_imports_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->string('name'); + $table->string('path'); + $table->integer('size'); + $table->integer('book_count'); + $table->integer('chapter_count'); + $table->integer('page_count'); + $table->integer('created_by'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('imports'); + } +}; From 8ea3855e02aa5ff7782dc65e1eee8b8b24f28ce6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 20:48:21 +0000 Subject: [PATCH 30/72] ZIP Import: Added upload handling Split attachment service storage work out so it can be shared. --- app/Exceptions/ZipValidationException.php | 12 ++ app/Exports/Controllers/ImportController.php | 41 ++----- app/Exports/ImportRepo.php | 48 ++++++++ app/Uploads/AttachmentService.php | 86 ++------------ app/Uploads/FileStorage.php | 111 +++++++++++++++++++ 5 files changed, 195 insertions(+), 103 deletions(-) create mode 100644 app/Exceptions/ZipValidationException.php create mode 100644 app/Exports/ImportRepo.php create mode 100644 app/Uploads/FileStorage.php diff --git a/app/Exceptions/ZipValidationException.php b/app/Exceptions/ZipValidationException.php new file mode 100644 index 00000000000..aaaee792ef0 --- /dev/null +++ b/app/Exceptions/ZipValidationException.php @@ -0,0 +1,12 @@ +middleware('can:content-import'); } @@ -27,35 +28,17 @@ public function start(Request $request) public function upload(Request $request) { $this->validate($request, [ - 'file' => ['required', 'file'] + 'file' => ['required', ...AttachmentService::getFileValidationRules()] ]); $file = $request->file('file'); - $zipPath = $file->getRealPath(); - - $errors = (new ZipExportValidator($zipPath))->validate(); - if ($errors) { - session()->flash('validation_errors', $errors); + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + session()->flash('validation_errors', $exception->errors); return redirect('/import'); } - $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); - $import = new Import(); - $import->name = $zipEntityInfo['name']; - $import->book_count = $zipEntityInfo['book_count']; - $import->chapter_count = $zipEntityInfo['chapter_count']; - $import->page_count = $zipEntityInfo['page_count']; - $import->created_by = user()->id; - $import->size = filesize($zipPath); - // TODO - Set path - // TODO - Save - - // TODO - Split out attachment service to separate out core filesystem/disk stuff - // To reuse for import handling - - dd('passed'); - // TODO - Upload to storage - // TODO - Store info/results for display: - // TODO - Send user to next import stage + return redirect("imports/{$import->id}"); } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php new file mode 100644 index 00000000000..c8157967bc3 --- /dev/null +++ b/app/Exports/ImportRepo.php @@ -0,0 +1,48 @@ +getRealPath(); + + $errors = (new ZipExportValidator($zipPath))->validate(); + if ($errors) { + throw new ZipValidationException($errors); + } + + $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $import = new Import(); + $import->name = $zipEntityInfo['name']; + $import->book_count = $zipEntityInfo['book_count']; + $import->chapter_count = $zipEntityInfo['chapter_count']; + $import->page_count = $zipEntityInfo['page_count']; + $import->created_by = user()->id; + $import->size = filesize($zipPath); + + $path = $this->storage->uploadFile( + $file, + 'uploads/files/imports/', + '', + 'zip' + ); + + $import->path = $path; + $import->save(); + + return $import; + } +} diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 227649d8f00..fa53c4ae499 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -4,59 +4,15 @@ use BookStack\Exceptions\FileUploadException; use Exception; -use Illuminate\Contracts\Filesystem\Filesystem as Storage; -use Illuminate\Filesystem\FilesystemManager; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class AttachmentService { public function __construct( - protected FilesystemManager $fileSystem + protected FileStorage $storage, ) { } - /** - * Get the storage that will be used for storing files. - */ - protected function getStorageDisk(): Storage - { - return $this->fileSystem->disk($this->getStorageDiskName()); - } - - /** - * Get the name of the storage disk to use. - */ - protected function getStorageDiskName(): string - { - $storageType = config('filesystems.attachments'); - - // Change to our secure-attachment disk if any of the local options - // are used to prevent escaping that location. - if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { - $storageType = 'local_secure_attachments'; - } - - return $storageType; - } - - /** - * Change the originally provided path to fit any disk-specific requirements. - * This also ensures the path is kept to the expected root folders. - */ - protected function adjustPathForStorageDisk(string $path): string - { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); - - if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; - } - - return 'uploads/files/' . $path; - } - /** * Stream an attachment from storage. * @@ -64,7 +20,7 @@ protected function adjustPathForStorageDisk(string $path): string */ public function streamAttachmentFromStorage(Attachment $attachment) { - return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getReadStream($attachment->path); } /** @@ -72,7 +28,7 @@ public function streamAttachmentFromStorage(Attachment $attachment) */ public function getAttachmentFileSize(Attachment $attachment): int { - return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getSize($attachment->path); } /** @@ -195,15 +151,9 @@ public function deleteFile(Attachment $attachment) * Delete a file from the filesystem it sits on. * Cleans any empty leftover folders. */ - protected function deleteFileInStorage(Attachment $attachment) + protected function deleteFileInStorage(Attachment $attachment): void { - $storage = $this->getStorageDisk(); - $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path)); - - $storage->delete($this->adjustPathForStorageDisk($attachment->path)); - if (count($storage->allFiles($dirPath)) === 0) { - $storage->deleteDirectory($dirPath); - } + $this->storage->delete($attachment->path); } /** @@ -213,32 +163,20 @@ protected function deleteFileInStorage(Attachment $attachment) */ protected function putFileInStorage(UploadedFile $uploadedFile): string { - $storage = $this->getStorageDisk(); $basePath = 'uploads/files/' . date('Y-m-M') . '/'; - $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension(); - while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { - $uploadFileName = Str::random(3) . $uploadFileName; - } - - $attachmentStream = fopen($uploadedFile->getRealPath(), 'r'); - $attachmentPath = $basePath . $uploadFileName; - - try { - $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream); - } catch (Exception $e) { - Log::error('Error when attempting file upload:' . $e->getMessage()); - - throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath])); - } - - return $attachmentPath; + return $this->storage->uploadFile( + $uploadedFile, + $basePath, + $uploadedFile->getClientOriginalExtension(), + '' + ); } /** * Get the file validation rules for attachments. */ - public function getFileValidationRules(): array + public static function getFileValidationRules(): array { return ['file', 'max:' . (config('app.upload_limit') * 1000)]; } diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php new file mode 100644 index 00000000000..278484e519d --- /dev/null +++ b/app/Uploads/FileStorage.php @@ -0,0 +1,111 @@ +getStorageDisk()->readStream($this->adjustPathForStorageDisk($path)); + } + + public function getSize(string $path): int + { + return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path)); + } + + public function delete(string $path, bool $removeEmptyDir = false): void + { + $storage = $this->getStorageDisk(); + $adjustedPath = $this->adjustPathForStorageDisk($path); + $dir = dirname($adjustedPath); + + $storage->delete($adjustedPath); + if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) { + $storage->deleteDirectory($dir); + } + } + + /** + * @throws FileUploadException + */ + public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string + { + $storage = $this->getStorageDisk(); + $basePath = trim($subDirectory, '/') . '/'; + + $uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : ''); + while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { + $uploadFileName = Str::random(3) . $uploadFileName; + } + + $fileStream = fopen($file->getRealPath(), 'r'); + $filePath = $basePath . $uploadFileName; + + try { + $storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream); + } catch (Exception $e) { + Log::error('Error when attempting file upload:' . $e->getMessage()); + + throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath])); + } + + return $filePath; + } + + /** + * Get the storage that will be used for storing files. + */ + protected function getStorageDisk(): Storage + { + return $this->fileSystem->disk($this->getStorageDiskName()); + } + + /** + * Get the name of the storage disk to use. + */ + protected function getStorageDiskName(): string + { + $storageType = config('filesystems.attachments'); + + // Change to our secure-attachment disk if any of the local options + // are used to prevent escaping that location. + if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { + $storageType = 'local_secure_attachments'; + } + + return $storageType; + } + + /** + * Change the originally provided path to fit any disk-specific requirements. + * This also ensures the path is kept to the expected root folders. + */ + protected function adjustPathForStorageDisk(string $path): string + { + $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + + if ($this->getStorageDiskName() === 'local_secure_attachments') { + return $path; + } + + return 'uploads/files/' . $path; + } +} From c6109c708735434fdb30333ff4c24b4a80b0b749 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Nov 2024 14:13:05 +0000 Subject: [PATCH 31/72] ZIP Imports: Added listing, show view, delete, activity --- app/Activity/ActivityType.php | 4 ++ app/Exports/Controllers/ImportController.php | 50 ++++++++++++++++++- app/Exports/Import.php | 23 ++++++++- app/Exports/ImportRepo.php | 32 ++++++++++++ app/Http/Controller.php | 4 +- lang/en/activities.php | 8 +++ lang/en/entities.php | 6 +++ resources/views/exports/import-show.blade.php | 38 ++++++++++++++ resources/views/exports/import.blade.php | 13 +++++ .../views/exports/parts/import.blade.php | 19 +++++++ routes/web.php | 2 + 11 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 resources/views/exports/import-show.blade.php create mode 100644 resources/views/exports/parts/import.blade.php diff --git a/app/Activity/ActivityType.php b/app/Activity/ActivityType.php index 09b2ae73c56..5ec9b9cf0dc 100644 --- a/app/Activity/ActivityType.php +++ b/app/Activity/ActivityType.php @@ -67,6 +67,10 @@ class ActivityType const WEBHOOK_UPDATE = 'webhook_update'; const WEBHOOK_DELETE = 'webhook_delete'; + const IMPORT_CREATE = 'import_create'; + const IMPORT_RUN = 'import_run'; + const IMPORT_DELETE = 'import_delete'; + /** * Get all the possible values. */ diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 640b4c10891..582fff975d2 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -1,7 +1,10 @@ middleware('can:content-import'); } + /** + * Show the view to start a new import, and also list out the existing + * in progress imports that are visible to the user. + */ public function start(Request $request) { - // TODO - Show existing imports for user (or for all users if admin-level user) + // TODO - Test visibility access for listed items + $imports = $this->imports->getVisibleImports(); + + $this->setPageTitle(trans('entities.import')); return view('exports.import', [ + 'imports' => $imports, 'zipErrors' => session()->pull('validation_errors') ?? [], ]); } + /** + * Upload, validate and store an import file. + */ public function upload(Request $request) { $this->validate($request, [ @@ -39,6 +53,38 @@ public function upload(Request $request) return redirect('/import'); } - return redirect("imports/{$import->id}"); + $this->logActivity(ActivityType::IMPORT_CREATE, $import); + + return redirect($import->getUrl()); + } + + /** + * Show a pending import, with a form to allow progressing + * with the import process. + */ + public function show(int $id) + { + // TODO - Test visibility access + $import = $this->imports->findVisible($id); + + $this->setPageTitle(trans('entities.import_continue')); + + return view('exports.import-show', [ + 'import' => $import, + ]); + } + + /** + * Delete an active pending import from the filesystem and database. + */ + public function delete(int $id) + { + // TODO - Test visibility access + $import = $this->imports->findVisible($id); + $this->imports->deleteImport($import); + + $this->logActivity(ActivityType::IMPORT_DELETE, $import); + + return redirect('/import'); } } diff --git a/app/Exports/Import.php b/app/Exports/Import.php index c3ac3d52924..520d8ea6cc8 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -2,6 +2,7 @@ namespace BookStack\Exports; +use BookStack\Activity\Models\Loggable; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,7 +18,7 @@ * @property Carbon $created_at * @property Carbon $updated_at */ -class Import extends Model +class Import extends Model implements Loggable { use HasFactory; @@ -38,4 +39,24 @@ public function getType(): string return self::TYPE_PAGE; } + + public function getSizeString(): string + { + $mb = round($this->size / 1000000, 2); + return "{$mb} MB"; + } + + /** + * Get the URL to view/continue this import. + */ + public function getUrl(string $path = ''): string + { + $path = ltrim($path, '/'); + return url("/import/{$this->id}" . ($path ? '/' . $path : '')); + } + + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index c8157967bc3..d7e169ad166 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -6,6 +6,7 @@ use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Uploads\FileStorage; +use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpFoundation\File\UploadedFile; class ImportRepo @@ -15,6 +16,31 @@ public function __construct( ) { } + /** + * @return Collection + */ + public function getVisibleImports(): Collection + { + $query = Import::query(); + + if (!userCan('settings-manage')) { + $query->where('created_by', user()->id); + } + + return $query->get(); + } + + public function findVisible(int $id): Import + { + $query = Import::query(); + + if (!userCan('settings-manage')) { + $query->where('created_by', user()->id); + } + + return $query->findOrFail($id); + } + public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); @@ -45,4 +71,10 @@ public function storeFromUpload(UploadedFile $file): Import return $import; } + + public function deleteImport(Import $import): void + { + $this->storage->delete($import->path); + $import->delete(); + } } diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 8facf5dab3c..090cf523ad2 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -152,10 +152,8 @@ protected function showErrorNotification(string $message): void /** * Log an activity in the system. - * - * @param string|Loggable $detail */ - protected function logActivity(string $type, $detail = ''): void + protected function logActivity(string $type, string|Loggable $detail = ''): void { Activity::add($type, $detail); } diff --git a/lang/en/activities.php b/lang/en/activities.php index 092398ef0e1..7c3454d41ca 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -84,6 +84,14 @@ 'webhook_delete' => 'deleted webhook', 'webhook_delete_notification' => 'Webhook successfully deleted', + // Imports + 'import_create' => 'created import', + 'import_create_notification' => 'Import successfully uploaded', + 'import_run' => 'updated import', + 'import_run_notification' => 'Content successfully imported', + 'import_delete' => 'deleted import', + 'import_delete_notification' => 'Import successfully deleted', + // Users 'user_create' => 'created user', 'user_create_notification' => 'User successfully created', diff --git a/lang/en/entities.php b/lang/en/entities.php index 10614733533..e2d8e47c592 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -48,6 +48,12 @@ 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', 'import_zip_select' => 'Select ZIP file to upload', 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', + 'import_pending' => 'Pending Imports', + 'import_pending_none' => 'No imports have been started.', + 'import_continue' => 'Continue Import', + 'import_run' => 'Run Import', + 'import_delete_confirm' => 'Are you sure you want to delete this import?', + 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php new file mode 100644 index 00000000000..843a052468c --- /dev/null +++ b/resources/views/exports/import-show.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.simple') + +@section('body') + +
    + +
    +

    {{ trans('entities.import_continue') }}

    + + {{ csrf_field() }} + + +
    + {{ trans('common.cancel') }} +
    + + +
    + +
    +
    +
    + +
    + {{ method_field('DELETE') }} + {{ csrf_field() }} +
    + +@stop diff --git a/resources/views/exports/import.blade.php b/resources/views/exports/import.blade.php index c4d7c881845..be9de4c0e91 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -38,6 +38,19 @@ class="custom-simple-file-input">
    + +
    +

    {{ trans('entities.import_pending') }}

    + @if(count($imports) === 0) +

    {{ trans('entities.import_pending_none') }}

    + @else +
    + @foreach($imports as $import) + @include('exports.parts.import', ['import' => $import]) + @endforeach +
    + @endif +
    @stop diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php new file mode 100644 index 00000000000..5ff6600f24b --- /dev/null +++ b/resources/views/exports/parts/import.blade.php @@ -0,0 +1,19 @@ +@php + $type = $import->getType(); +@endphp +
    + +
    + @if($type === 'book') +
    @icon('chapter') {{ $import->chapter_count }}
    + @endif + @if($type === 'book' || $type === 'chapter') +
    @icon('page') {{ $import->page_count }}
    + @endif +
    {{ $import->getSizeString() }}
    +
    @icon('time'){{ $import->created_at->diffForHumans() }}
    +
    +
    \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 91aab13fecf..c490bb3b34e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -209,6 +209,8 @@ // Importing Route::get('/import', [ExportControllers\ImportController::class, 'start']); Route::post('/import', [ExportControllers\ImportController::class, 'upload']); + Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']); + Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']); // Other Pages Route::get('/', [HomeController::class, 'index']); From 8f6f81948e81b4d63251bee57da57aa5809eaad2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 3 Nov 2024 17:28:18 +0000 Subject: [PATCH 32/72] ZIP Imports: Fleshed out continue page, Added testing --- app/Exports/Controllers/ImportController.php | 17 ++- app/Exports/Import.php | 9 ++ lang/en/entities.php | 4 + resources/views/exports/import-show.blade.php | 41 ++++- routes/web.php | 1 + tests/Exports/ZipImportTest.php | 140 ++++++++++++++++++ 6 files changed, 206 insertions(+), 6 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 582fff975d2..787fd1b27e0 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -23,9 +23,8 @@ public function __construct( * Show the view to start a new import, and also list out the existing * in progress imports that are visible to the user. */ - public function start(Request $request) + public function start() { - // TODO - Test visibility access for listed items $imports = $this->imports->getVisibleImports(); $this->setPageTitle(trans('entities.import')); @@ -64,7 +63,6 @@ public function upload(Request $request) */ public function show(int $id) { - // TODO - Test visibility access $import = $this->imports->findVisible($id); $this->setPageTitle(trans('entities.import_continue')); @@ -74,12 +72,23 @@ public function show(int $id) ]); } + public function run(int $id) + { + // TODO - Test access/visibility + + $import = $this->imports->findVisible($id); + + // TODO - Run import + // Validate again before + // TODO - Redirect to result + // TOOD - Or redirect back with errors + } + /** * Delete an active pending import from the filesystem and database. */ public function delete(int $id) { - // TODO - Test visibility access $import = $this->imports->findVisible($id); $this->imports->deleteImport($import); diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 520d8ea6cc8..8400382fd0d 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -3,11 +3,14 @@ namespace BookStack\Exports; use BookStack\Activity\Models\Loggable; +use BookStack\Users\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; /** + * @property int $id * @property string $path * @property string $name * @property int $size - ZIP size in bytes @@ -17,6 +20,7 @@ * @property int $created_by * @property Carbon $created_at * @property Carbon $updated_at + * @property User $createdBy */ class Import extends Model implements Loggable { @@ -59,4 +63,9 @@ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } } diff --git a/lang/en/entities.php b/lang/en/entities.php index e2d8e47c592..4f5a530049b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -51,7 +51,11 @@ 'import_pending' => 'Pending Imports', 'import_pending_none' => 'No imports have been started.', 'import_continue' => 'Continue Import', + 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', 'import_run' => 'Run Import', + 'import_size' => 'Import ZIP Size:', + 'import_uploaded_at' => 'Uploaded:', + 'import_uploaded_by' => 'Uploaded by:', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index 843a052468c..ac1b8a45d06 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -6,7 +6,44 @@

    {{ trans('entities.import_continue') }}

    -
    +

    {{ trans('entities.import_continue_desc') }}

    + +
    + @php + $type = $import->getType(); + @endphp +
    +
    +

    @icon($type) {{ $import->name }}

    + @if($type === 'book') +

    @icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}

    + @endif + @if($type === 'book' || $type === 'chapter') +

    @icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}

    + @endif +
    +
    +
    + {{ trans('entities.import_size') }} + {{ $import->getSizeString() }} +
    +
    + {{ trans('entities.import_uploaded_at') }} + {{ $import->created_at->diffForHumans() }} +
    + @if($import->createdBy) +
    + {{ trans('entities.import_uploaded_by') }} + {{ $import->createdBy->name }} +
    + @endif +
    +
    +
    + + {{ csrf_field() }}
    @@ -23,7 +60,7 @@ class="button outline">{{ trans('common.delete') }} - +
    diff --git a/routes/web.php b/routes/web.php index c490bb3b34e..85f83352859 100644 --- a/routes/web.php +++ b/routes/web.php @@ -210,6 +210,7 @@ Route::get('/import', [ExportControllers\ImportController::class, 'start']); Route::post('/import', [ExportControllers\ImportController::class, 'upload']); Route::get('/import/{id}', [ExportControllers\ImportController::class, 'show']); + Route::post('/import/{id}', [ExportControllers\ImportController::class, 'run']); Route::delete('/import/{id}', [ExportControllers\ImportController::class, 'delete']); // Other Pages diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index c9d255b1e97..b9a8598fabe 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -2,6 +2,8 @@ namespace Tests\Exports; +use BookStack\Activity\ActivityType; +use BookStack\Exports\Import; use Illuminate\Http\UploadedFile; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -35,6 +37,25 @@ public function test_permissions_needed_for_import_page() $resp->assertSeeText('Select ZIP file to upload'); } + public function test_import_page_pending_import_visibility_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $resp = $this->actingAs($user)->get('/import'); + $resp->assertSeeText('MySuperUserImport'); + $resp->assertDontSeeText('MySuperAdminImport'); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $resp = $this->actingAs($user)->get('/import'); + $resp->assertSeeText('MySuperUserImport'); + $resp->assertSeeText('MySuperAdminImport'); + } + public function test_zip_read_errors_are_shown_on_validation() { $invalidUpload = $this->files->uploadedImage('image.zip'); @@ -105,6 +126,125 @@ public function test_zip_data_validation_messages_shown() $resp->assertSeeText('[book.pages.1.tags.0.value]: The value must be a string.'); } + public function test_import_upload_success() + { + $admin = $this->users->admin(); + $this->actingAs($admin); + $resp = $this->runImportFromFile($this->zipUploadFromData([ + 'book' => [ + 'name' => 'My great book name', + 'chapters' => [ + [ + 'name' => 'my chapter', + 'pages' => [ + [ + 'name' => 'my chapter page', + ] + ] + ] + ], + 'pages' => [ + [ + 'name' => 'My page', + ] + ], + ], + ])); + + $this->assertDatabaseHas('imports', [ + 'name' => 'My great book name', + 'book_count' => 1, + 'chapter_count' => 1, + 'page_count' => 2, + 'created_by' => $admin->id, + ]); + + /** @var Import $import */ + $import = Import::query()->latest()->first(); + $resp->assertRedirect("/import/{$import->id}"); + $this->assertFileExists(storage_path($import->path)); + $this->assertActivityExists(ActivityType::IMPORT_CREATE); + } + + public function test_import_show_page() + { + $import = Import::factory()->create(['name' => 'MySuperAdminImport']); + + $resp = $this->asAdmin()->get("/import/{$import->id}"); + $resp->assertOk(); + $resp->assertSee('MySuperAdminImport'); + } + + public function test_import_show_page_access_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->get("/import/{$userImport->id}")->assertRedirect('/'); + $this->get("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->get("/import/{$userImport->id}")->assertOk(); + $this->get("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->get("/import/{$userImport->id}")->assertOk(); + $this->get("/import/{$adminImport->id}")->assertOk(); + } + + public function test_import_delete() + { + $this->asAdmin(); + $this->runImportFromFile($this->zipUploadFromData([ + 'book' => [ + 'name' => 'My great book name' + ], + ])); + + /** @var Import $import */ + $import = Import::query()->latest()->first(); + $this->assertDatabaseHas('imports', [ + 'id' => $import->id, + 'name' => 'My great book name' + ]); + $this->assertFileExists(storage_path($import->path)); + + $resp = $this->delete("/import/{$import->id}"); + + $resp->assertRedirect('/import'); + $this->assertActivityExists(ActivityType::IMPORT_DELETE); + $this->assertDatabaseMissing('imports', [ + 'id' => $import->id, + ]); + $this->assertFileDoesNotExist(storage_path($import->path)); + } + + public function test_import_delete_access_limited() + { + $user = $this->users->viewer(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); + + $this->delete("/import/{$userImport->id}")->assertRedirect('/'); + $this->delete("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->delete("/import/{$userImport->id}")->assertRedirect('/import'); + $this->delete("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->delete("/import/{$adminImport->id}")->assertRedirect('/import'); + } + protected function runImportFromFile(UploadedFile $file): TestResponse { return $this->call('POST', '/import', [], [], ['file' => $file]); From 14578c22570d7f9ac197125ece1cf86d9d07be9b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 4 Nov 2024 16:21:22 +0000 Subject: [PATCH 33/72] ZIP Imports: Added parent selector for page/chapter imports --- app/Exports/Controllers/ImportController.php | 14 ++++++++--- lang/en/entities.php | 2 ++ resources/sass/styles.scss | 18 +++++++++++++ resources/views/entities/selector.blade.php | 8 ++++++ resources/views/exports/import-show.blade.php | 25 +++++++++++++++---- resources/views/form/errors.blade.php | 3 +++ 6 files changed, 62 insertions(+), 8 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 787fd1b27e0..a2389c725f3 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -72,14 +72,22 @@ public function show(int $id) ]); } - public function run(int $id) + public function run(int $id, Request $request) { // TODO - Test access/visibility - $import = $this->imports->findVisible($id); + $parent = null; + + if ($import->getType() === 'page' || $import->getType() === 'chapter') { + $data = $this->validate($request, [ + 'parent' => ['required', 'string'] + ]); + $parent = $data['parent']; + } // TODO - Run import - // Validate again before + // TODO - Validate again before + // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) // TODO - Redirect to result // TOOD - Or redirect back with errors } diff --git a/lang/en/entities.php b/lang/en/entities.php index 4f5a530049b..065eb043a11 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -56,6 +56,8 @@ 'import_size' => 'Import ZIP Size:', 'import_uploaded_at' => 'Uploaded:', 'import_uploaded_by' => 'Uploaded by:', + 'import_location' => 'Import Location', + 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 636367e3aeb..942265d04d8 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -138,6 +138,11 @@ $loadingSize: 10px; font-size: 16px; padding: $-s $-m; } + input[type="text"]:focus { + outline: 1px solid var(--color-primary); + border-radius: 3px 3px 0 0; + outline-offset: -1px; + } .entity-list { overflow-y: scroll; height: 400px; @@ -171,6 +176,19 @@ $loadingSize: 10px; font-size: 14px; } } + &.small { + .entity-list-item { + padding: $-xs $-m; + } + .entity-list, .loading { + height: 300px; + } + input[type="text"] { + font-size: 13px; + padding: $-xs $-m; + height: auto; + } + } } .fullscreen { diff --git a/resources/views/entities/selector.blade.php b/resources/views/entities/selector.blade.php index c1280cfb2f7..0cdf4376cc7 100644 --- a/resources/views/entities/selector.blade.php +++ b/resources/views/entities/selector.blade.php @@ -1,3 +1,11 @@ +{{-- +$name - string +$autofocus - boolean, optional +$entityTypes - string, optional +$entityPermission - string, optional +$selectorEndpoint - string, optional +$selectorSize - string, optional (compact) +--}}
    getType(); + @endphp +
    @@ -9,11 +13,9 @@

    {{ trans('entities.import_continue_desc') }}

    - @php - $type = $import->getType(); - @endphp +
    -
    +

    @icon($type) {{ $import->name }}

    @if($type === 'book')

    @icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}

    @@ -22,7 +24,7 @@

    @icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}

    @endif
    -
    +
    {{ trans('entities.import_size') }} {{ $import->getSizeString() }} @@ -45,6 +47,19 @@ action="{{ $import->getUrl() }}" method="POST"> {{ csrf_field() }} + + @if($type === 'page' || $type === 'chapter') +
    + +

    {{ trans('entities.import_location_desc') }}

    + @include('entities.selector', [ + 'name' => 'parent', + 'entityTypes' => $type === 'page' ? 'chapter,book' : 'book', + 'entityPermission' => "{$type}-create", + 'selectorSize' => 'compact small', + ]) + @include('form.errors', ['name' => 'parent']) + @endif
    diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php index 03cd4be88f0..72d41ee56c7 100644 --- a/resources/views/form/errors.blade.php +++ b/resources/views/form/errors.blade.php @@ -1,3 +1,6 @@ +{{-- +$name - string +--}} @if($errors->has($name))
    {{ $errors->first($name) }}
    @endif \ No newline at end of file From 92cfde495e0d3141af608ea3734b612402f257dd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 Nov 2024 13:17:31 +0000 Subject: [PATCH 34/72] ZIP Imports: Added full contents view to import display Reduced import data will now be stored on the import itself, instead of storing a set of totals. --- app/Exports/Controllers/ImportController.php | 5 ++- app/Exports/Import.php | 37 ++++++++---------- app/Exports/ImportRepo.php | 35 ++++++++++++++--- .../ZipExports/Models/ZipExportAttachment.php | 18 +++++++++ .../ZipExports/Models/ZipExportBook.php | 30 ++++++++++++++ .../ZipExports/Models/ZipExportChapter.php | 26 +++++++++++++ .../ZipExports/Models/ZipExportImage.php | 17 ++++++++ .../ZipExports/Models/ZipExportModel.php | 28 +++++++++++++ .../ZipExports/Models/ZipExportPage.php | 31 +++++++++++++++ .../ZipExports/Models/ZipExportTag.php | 16 ++++++++ app/Exports/ZipExports/ZipExportReader.php | 32 ++++++--------- app/Exports/ZipExports/ZipExportValidator.php | 1 - database/factories/Exports/ImportFactory.php | 5 +-- ...2024_11_02_160700_create_imports_table.php | 7 ++-- lang/en/entities.php | 8 ++-- resources/sass/styles.scss | 5 +++ resources/views/exports/import-show.blade.php | 39 ++++++------------- .../views/exports/parts/import-item.blade.php | 26 +++++++++++++ .../views/exports/parts/import.blade.php | 11 +----- tests/Exports/ZipImportTest.php | 31 +++++++++++---- 20 files changed, 303 insertions(+), 105 deletions(-) create mode 100644 resources/views/exports/parts/import-item.blade.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index a2389c725f3..3a56ed03456 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -65,10 +65,13 @@ public function show(int $id) { $import = $this->imports->findVisible($id); +// dd($import->decodeMetadata()); + $this->setPageTitle(trans('entities.import_continue')); return view('exports.import-show', [ 'import' => $import, + 'data' => $import->decodeMetadata(), ]); } @@ -89,7 +92,7 @@ public function run(int $id, Request $request) // TODO - Validate again before // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) // TODO - Redirect to result - // TOOD - Or redirect back with errors + // TODO - Or redirect back with errors } /** diff --git a/app/Exports/Import.php b/app/Exports/Import.php index 8400382fd0d..9c1771c468f 100644 --- a/app/Exports/Import.php +++ b/app/Exports/Import.php @@ -3,6 +3,9 @@ namespace BookStack\Exports; use BookStack\Activity\Models\Loggable; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Users\Models\User; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -14,9 +17,8 @@ * @property string $path * @property string $name * @property int $size - ZIP size in bytes - * @property int $book_count - * @property int $chapter_count - * @property int $page_count + * @property string $type + * @property string $metadata * @property int $created_by * @property Carbon $created_at * @property Carbon $updated_at @@ -26,24 +28,6 @@ class Import extends Model implements Loggable { use HasFactory; - public const TYPE_BOOK = 'book'; - public const TYPE_CHAPTER = 'chapter'; - public const TYPE_PAGE = 'page'; - - /** - * Get the type (model) that this import is intended to be. - */ - public function getType(): string - { - if ($this->book_count === 1) { - return self::TYPE_BOOK; - } elseif ($this->chapter_count === 1) { - return self::TYPE_CHAPTER; - } - - return self::TYPE_PAGE; - } - public function getSizeString(): string { $mb = round($this->size / 1000000, 2); @@ -68,4 +52,15 @@ public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); } + + public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null + { + $metadataArray = json_decode($this->metadata, true); + return match ($this->type) { + 'book' => ZipExportBook::fromArray($metadataArray), + 'chapter' => ZipExportChapter::fromArray($metadataArray), + 'page' => ZipExportPage::fromArray($metadataArray), + default => null, + }; + } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index d7e169ad166..3265e1c80dc 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,7 +2,12 @@ namespace BookStack\Exports; +use BookStack\Exceptions\FileUploadException; +use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipValidationException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Uploads\FileStorage; @@ -41,6 +46,11 @@ public function findVisible(int $id): Import return $query->findOrFail($id); } + /** + * @throws FileUploadException + * @throws ZipValidationException + * @throws ZipExportException + */ public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); @@ -50,15 +60,23 @@ public function storeFromUpload(UploadedFile $file): Import throw new ZipValidationException($errors); } - $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $reader = new ZipExportReader($zipPath); + $exportModel = $reader->decodeDataToExportModel(); + $import = new Import(); - $import->name = $zipEntityInfo['name']; - $import->book_count = $zipEntityInfo['book_count']; - $import->chapter_count = $zipEntityInfo['chapter_count']; - $import->page_count = $zipEntityInfo['page_count']; + $import->type = match (get_class($exportModel)) { + ZipExportPage::class => 'page', + ZipExportChapter::class => 'chapter', + ZipExportBook::class => 'book', + }; + + $import->name = $exportModel->name; $import->created_by = user()->id; $import->size = filesize($zipPath); + $exportModel->metadataOnly(); + $import->metadata = json_encode($exportModel); + $path = $this->storage->uploadFile( $file, 'uploads/files/imports/', @@ -72,6 +90,13 @@ public function storeFromUpload(UploadedFile $file): Import return $import; } + public function runImport(Import $import, ?string $parent = null) + { + // TODO - Download import zip (if needed) + // TODO - Validate zip file again + // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) + } + public function deleteImport(Import $import): void { $this->storage->delete($import->path); diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index e586b91b0ee..1dbdc7333e8 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -14,6 +14,11 @@ class ZipExportAttachment extends ZipExportModel public ?string $link = null; public ?string $file = null; + public function metadataOnly(): void + { + $this->order = $this->link = $this->file = null; + } + public static function fromModel(Attachment $model, ZipExportFiles $files): self { $instance = new self(); @@ -49,4 +54,17 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $context->validateData($data, $rules); } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->order = isset($data['order']) ? intval($data['order']) : null; + $model->link = $data['link'] ?? null; + $model->file = $data['file'] ?? null; + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 7e1f2d8106e..0dc4e93d43c 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -21,6 +21,21 @@ class ZipExportBook extends ZipExportModel /** @var ZipExportTag[] */ public array $tags = []; + public function metadataOnly(): void + { + $this->description_html = $this->cover = null; + + foreach ($this->chapters as $chapter) { + $chapter->metadataOnly(); + } + foreach ($this->pages as $page) { + $page->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + public static function fromModel(Book $model, ZipExportFiles $files): self { $instance = new self(); @@ -71,4 +86,19 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $errors; } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->description_html = $data['description_html'] ?? null; + $model->cover = $data['cover'] ?? null; + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []); + $model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []); + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 03df31b7078..50440d61a5a 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -18,6 +18,18 @@ class ZipExportChapter extends ZipExportModel /** @var ZipExportTag[] */ public array $tags = []; + public function metadataOnly(): void + { + $this->description_html = $this->priority = null; + + foreach ($this->pages as $page) { + $page->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + public static function fromModel(Chapter $model, ZipExportFiles $files): self { $instance = new self(); @@ -61,4 +73,18 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $errors; } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->description_html = $data['description_html'] ?? null; + $model->priority = isset($data['priority']) ? intval($data['priority']) : null; + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + $model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []); + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 3388c66df36..691eb918fc6 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -25,6 +25,11 @@ public static function fromModel(Image $model, ZipExportFiles $files): self return $instance; } + public function metadataOnly(): void + { + // + } + public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ @@ -36,4 +41,16 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $context->validateData($data, $rules); } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->file = $data['file']; + $model->type = $data['type']; + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportModel.php b/app/Exports/ZipExports/Models/ZipExportModel.php index 4d66f010f86..d3a8c35674b 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -26,4 +26,32 @@ public function jsonSerialize(): array * item in the array for its own validation messages. */ abstract public static function validate(ZipValidationHelper $context, array $data): array; + + /** + * Decode the array of data into this export model. + */ + abstract public static function fromArray(array $data): self; + + /** + * Decode an array of array data into an array of export models. + * @param array[] $data + * @return self[] + */ + public static function fromManyArray(array $data): array + { + $results = []; + foreach ($data as $item) { + $results[] = static::fromArray($item); + } + return $results; + } + + /** + * Remove additional content in this model to reduce it down + * to just essential id/name values for identification. + * + * The result of this may be something that does not pass validation, but is + * simple for the purpose of creating a contents. + */ + abstract public function metadataOnly(): void; } diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 2c8b9a88abd..3a876e7aaff 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -21,6 +21,21 @@ class ZipExportPage extends ZipExportModel /** @var ZipExportTag[] */ public array $tags = []; + public function metadataOnly(): void + { + $this->html = $this->markdown = $this->priority = null; + + foreach ($this->attachments as $attachment) { + $attachment->metadataOnly(); + } + foreach ($this->images as $image) { + $image->metadataOnly(); + } + foreach ($this->tags as $tag) { + $tag->metadataOnly(); + } + } + public static function fromModel(Page $model, ZipExportFiles $files): self { $instance = new self(); @@ -70,4 +85,20 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $errors; } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->id = $data['id'] ?? null; + $model->name = $data['name']; + $model->html = $data['html'] ?? null; + $model->markdown = $data['markdown'] ?? null; + $model->priority = isset($data['priority']) ? intval($data['priority']) : null; + $model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []); + $model->images = ZipExportImage::fromManyArray($data['images'] ?? []); + $model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []); + + return $model; + } } diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index 99abb811c06..b6c9e338aef 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -11,6 +11,11 @@ class ZipExportTag extends ZipExportModel public ?string $value = null; public ?int $order = null; + public function metadataOnly(): void + { + $this->value = $this->order = null; + } + public static function fromModel(Tag $model): self { $instance = new self(); @@ -36,4 +41,15 @@ public static function validate(ZipValidationHelper $context, array $data): arra return $context->validateData($data, $rules); } + + public static function fromArray(array $data): self + { + $model = new self(); + + $model->name = $data['name']; + $model->value = $data['value'] ?? null; + $model->order = isset($data['order']) ? intval($data['order']) : null; + + return $model; + } } diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index 7187a18897d..c3e47da048d 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -3,6 +3,10 @@ namespace BookStack\Exports\ZipExports; use BookStack\Exceptions\ZipExportException; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportModel; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use ZipArchive; class ZipExportReader @@ -71,32 +75,18 @@ public function fileExists(string $fileName): bool /** * @throws ZipExportException - * @returns array{name: string, book_count: int, chapter_count: int, page_count: int} */ - public function getEntityInfo(): array + public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage { $data = $this->readData(); - $info = ['name' => '', 'book_count' => 0, 'chapter_count' => 0, 'page_count' => 0]; - if (isset($data['book'])) { - $info['name'] = $data['book']['name'] ?? ''; - $info['book_count']++; - $chapters = $data['book']['chapters'] ?? []; - $pages = $data['book']['pages'] ?? []; - $info['chapter_count'] += count($chapters); - $info['page_count'] += count($pages); - foreach ($chapters as $chapter) { - $info['page_count'] += count($chapter['pages'] ?? []); - } - } elseif (isset($data['chapter'])) { - $info['name'] = $data['chapter']['name'] ?? ''; - $info['chapter_count']++; - $info['page_count'] += count($data['chapter']['pages'] ?? []); - } elseif (isset($data['page'])) { - $info['name'] = $data['page']['name'] ?? ''; - $info['page_count']++; + return ZipExportBook::fromArray($data['book']); + } else if (isset($data['chapter'])) { + return ZipExportChapter::fromArray($data['chapter']); + } else if (isset($data['page'])) { + return ZipExportPage::fromArray($data['page']); } - return $info; + throw new ZipExportException("Could not identify content in ZIP file data."); } } diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index e476998c216..e27ae53c774 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -38,7 +38,6 @@ public function validate(): array return ['format' => trans('errors.import_zip_no_data')]; } - return $this->flattenModelErrors($modelErrors, $keyPrefix); } diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 55378d5832e..74a2bcd65f3 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -23,9 +23,8 @@ public function definition(): array return [ 'path' => 'uploads/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), - 'book_count' => 1, - 'chapter_count' => 5, - 'page_count' => 15, + 'type' => 'book', + 'metadata' => '{"name": "My book"}', 'created_at' => User::factory(), ]; } diff --git a/database/migrations/2024_11_02_160700_create_imports_table.php b/database/migrations/2024_11_02_160700_create_imports_table.php index ed188226981..0784591b8e3 100644 --- a/database/migrations/2024_11_02_160700_create_imports_table.php +++ b/database/migrations/2024_11_02_160700_create_imports_table.php @@ -16,10 +16,9 @@ public function up(): void $table->string('name'); $table->string('path'); $table->integer('size'); - $table->integer('book_count'); - $table->integer('chapter_count'); - $table->integer('page_count'); - $table->integer('created_by'); + $table->string('type'); + $table->longText('metadata'); + $table->integer('created_by')->index(); $table->timestamps(); }); } diff --git a/lang/en/entities.php b/lang/en/entities.php index 065eb043a11..ae1c1e8d4cc 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -45,7 +45,7 @@ 'default_template_select' => 'Select a template page', 'import' => 'Import', 'import_validate' => 'Validate Import', - 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to import then press "Validate Import" to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', + 'import_desc' => 'Import books, chapters & pages using a portable zip export from the same, or a different, instance. Select a ZIP file to proceed. After the file has been uploaded and validated you\'ll be able to configure & confirm the import in the next view.', 'import_zip_select' => 'Select ZIP file to upload', 'import_zip_validation_errors' => 'Errors were detected while validating the provided ZIP file:', 'import_pending' => 'Pending Imports', @@ -53,9 +53,9 @@ 'import_continue' => 'Continue Import', 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', 'import_run' => 'Run Import', - 'import_size' => 'Import ZIP Size:', - 'import_uploaded_at' => 'Uploaded:', - 'import_uploaded_by' => 'Uploaded by:', + 'import_size' => ':size Import ZIP Size', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Uploaded by', 'import_location' => 'Import Location', 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 942265d04d8..2cf3cbf8221 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -248,4 +248,9 @@ $loadingSize: 10px; transform: rotate(180deg); } } +} + +.import-item { + border-inline-start: 2px solid currentColor; + padding-inline-start: $-xs; } \ No newline at end of file diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index 63977947dba..40867377fb0 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -1,11 +1,6 @@ @extends('layouts.simple') @section('body') - - @php - $type = $import->getType(); - @endphp -
    @@ -13,29 +8,17 @@

    {{ trans('entities.import_continue_desc') }}

    - -
    + +
    -

    @icon($type) {{ $import->name }}

    - @if($type === 'book') -

    @icon('chapter') {{ trans_choice('entities.x_chapters', $import->chapter_count) }}

    - @endif - @if($type === 'book' || $type === 'chapter') -

    @icon('page') {{ trans_choice('entities.x_pages', $import->page_count) }}

    - @endif + @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data])
    -
    -
    - {{ trans('entities.import_size') }} - {{ $import->getSizeString() }} -
    -
    - {{ trans('entities.import_uploaded_at') }} - {{ $import->created_at->diffForHumans() }} -
    +
    +
    {{ trans('entities.import_size', ['size' => $import->getSizeString()]) }}
    +
    {{ trans('entities.import_uploaded_at', ['relativeTime' => $import->created_at->diffForHumans()]) }}
    @if($import->createdBy) -
    - {{ trans('entities.import_uploaded_by') }} +
    + {{ trans('entities.import_uploaded_by') }} {{ $import->createdBy->name }}
    @endif @@ -48,14 +31,14 @@ method="POST"> {{ csrf_field() }} - @if($type === 'page' || $type === 'chapter') + @if($import->type === 'page' || $import->type === 'chapter')

    {{ trans('entities.import_location_desc') }}

    @include('entities.selector', [ 'name' => 'parent', - 'entityTypes' => $type === 'page' ? 'chapter,book' : 'book', - 'entityPermission' => "{$type}-create", + 'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book', + 'entityPermission' => "{$import->type}-create", 'selectorSize' => 'compact small', ]) @include('form.errors', ['name' => 'parent']) diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php new file mode 100644 index 00000000000..811a3b31bda --- /dev/null +++ b/resources/views/exports/parts/import-item.blade.php @@ -0,0 +1,26 @@ +{{-- +$type - string +$model - object +--}} +
    +

    @icon($type){{ $model->name }}

    +
    +
    + @if($model->attachments ?? []) + @icon('attach'){{ count($model->attachments) }} + @endif + @if($model->images ?? []) + @icon('image'){{ count($model->images) }} + @endif + @if($model->tags ?? []) + @icon('tag'){{ count($model->tags) }} + @endif +
    + @foreach($model->chapters ?? [] as $chapter) + @include('exports.parts.import-item', ['type' => 'chapter', 'model' => $chapter]) + @endforeach + @foreach($model->pages ?? [] as $page) + @include('exports.parts.import-item', ['type' => 'page', 'model' => $page]) + @endforeach +
    +
    \ No newline at end of file diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php index 5ff6600f24b..fd53095a422 100644 --- a/resources/views/exports/parts/import.blade.php +++ b/resources/views/exports/parts/import.blade.php @@ -1,18 +1,9 @@ -@php - $type = $import->getType(); -@endphp
    @icon($type) {{ $import->name }} + class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}
    - @if($type === 'book') -
    @icon('chapter') {{ $import->chapter_count }}
    - @endif - @if($type === 'book' || $type === 'chapter') -
    @icon('page') {{ $import->page_count }}
    - @endif
    {{ $import->getSizeString() }}
    @icon('time'){{ $import->created_at->diffForHumans() }}
    diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index b9a8598fabe..2b40100aabe 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -4,6 +4,9 @@ use BookStack\Activity\ActivityType; use BookStack\Exports\Import; +use BookStack\Exports\ZipExports\Models\ZipExportBook; +use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportPage; use Illuminate\Http\UploadedFile; use Illuminate\Testing\TestResponse; use Tests\TestCase; @@ -130,7 +133,7 @@ public function test_import_upload_success() { $admin = $this->users->admin(); $this->actingAs($admin); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $data = [ 'book' => [ 'name' => 'My great book name', 'chapters' => [ @@ -149,13 +152,13 @@ public function test_import_upload_success() ] ], ], - ])); + ]; + + $resp = $this->runImportFromFile($this->zipUploadFromData($data)); $this->assertDatabaseHas('imports', [ 'name' => 'My great book name', - 'book_count' => 1, - 'chapter_count' => 1, - 'page_count' => 2, + 'type' => 'book', 'created_by' => $admin->id, ]); @@ -168,11 +171,25 @@ public function test_import_upload_success() public function test_import_show_page() { - $import = Import::factory()->create(['name' => 'MySuperAdminImport']); + $exportBook = new ZipExportBook(); + $exportBook->name = 'My exported book'; + $exportChapter = new ZipExportChapter(); + $exportChapter->name = 'My exported chapter'; + $exportPage = new ZipExportPage(); + $exportPage->name = 'My exported page'; + $exportBook->chapters = [$exportChapter]; + $exportChapter->pages = [$exportPage]; + + $import = Import::factory()->create([ + 'name' => 'MySuperAdminImport', + 'metadata' => json_encode($exportBook) + ]); $resp = $this->asAdmin()->get("/import/{$import->id}"); $resp->assertOk(); - $resp->assertSee('MySuperAdminImport'); + $resp->assertSeeText('My exported book'); + $resp->assertSeeText('My exported chapter'); + $resp->assertSeeText('My exported page'); } public function test_import_show_page_access_limited() From 7b84558ca1deb0a605a2f632e60baaad325615e7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 5 Nov 2024 15:41:58 +0000 Subject: [PATCH 35/72] ZIP Imports: Added parent and permission check pre-import --- app/Exceptions/ZipImportException.php | 12 ++ app/Exports/Controllers/ImportController.php | 2 - app/Exports/ImportRepo.php | 20 ++- app/Exports/ZipExports/ZipExportValidator.php | 7 +- app/Exports/ZipExports/ZipImportRunner.php | 143 ++++++++++++++++++ app/Uploads/FileStorage.php | 23 ++- 6 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 app/Exceptions/ZipImportException.php create mode 100644 app/Exports/ZipExports/ZipImportRunner.php diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php new file mode 100644 index 00000000000..2403c514477 --- /dev/null +++ b/app/Exceptions/ZipImportException.php @@ -0,0 +1,12 @@ +imports->findVisible($id); -// dd($import->decodeMetadata()); - $this->setPageTitle(trans('entities.import_continue')); return view('exports.import-show', [ diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index 3265e1c80dc..b94563545a4 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,6 +2,7 @@ namespace BookStack\Exports; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipValidationException; @@ -10,6 +11,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; +use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Uploads\FileStorage; use Illuminate\Database\Eloquent\Collection; use Symfony\Component\HttpFoundation\File\UploadedFile; @@ -18,6 +20,8 @@ class ImportRepo { public function __construct( protected FileStorage $storage, + protected ZipImportRunner $importer, + protected EntityQueries $entityQueries, ) { } @@ -54,13 +58,13 @@ public function findVisible(int $id): Import public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); + $reader = new ZipExportReader($zipPath); - $errors = (new ZipExportValidator($zipPath))->validate(); + $errors = (new ZipExportValidator($reader))->validate(); if ($errors) { throw new ZipValidationException($errors); } - $reader = new ZipExportReader($zipPath); $exportModel = $reader->decodeDataToExportModel(); $import = new Import(); @@ -90,11 +94,17 @@ public function storeFromUpload(UploadedFile $file): Import return $import; } + /** + * @throws ZipValidationException + */ public function runImport(Import $import, ?string $parent = null) { - // TODO - Download import zip (if needed) - // TODO - Validate zip file again - // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) + $parentModel = null; + if ($import->type === 'page' || $import->type === 'chapter') { + $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; + } + + return $this->importer->run($import, $parentModel); } public function deleteImport(Import $import): void diff --git a/app/Exports/ZipExports/ZipExportValidator.php b/app/Exports/ZipExports/ZipExportValidator.php index e27ae53c774..889804f2013 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -10,20 +10,19 @@ class ZipExportValidator { public function __construct( - protected string $zipPath, + protected ZipExportReader $reader, ) { } public function validate(): array { - $reader = new ZipExportReader($this->zipPath); try { - $importData = $reader->readData(); + $importData = $this->reader->readData(); } catch (ZipExportException $exception) { return ['format' => $exception->getMessage()]; } - $helper = new ZipValidationHelper($reader); + $helper = new ZipValidationHelper($this->reader); if (isset($importData['book'])) { $modelErrors = ZipExportBook::validate($helper, $importData['book']); diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php new file mode 100644 index 00000000000..2f784ebea6e --- /dev/null +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -0,0 +1,143 @@ +getZipPath($import); + $reader = new ZipExportReader($zipPath); + + $errors = (new ZipExportValidator($reader))->validate(); + if ($errors) { + throw new ZipImportException(["ZIP failed to validate"]); + } + + try { + $exportModel = $reader->decodeDataToExportModel(); + } catch (ZipExportException $e) { + throw new ZipImportException([$e->getMessage()]); + } + + // Validate parent type + if ($exportModel instanceof ZipExportBook && ($parent !== null)) { + throw new ZipImportException(["Must not have a parent set for a Book import"]); + } else if ($exportModel instanceof ZipExportChapter && (!$parent instanceof Book)) { + throw new ZipImportException(["Parent book required for chapter import"]); + } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) { + throw new ZipImportException(["Parent book or chapter required for page import"]); + } + + $this->ensurePermissionsPermitImport($exportModel); + + // TODO - Run import + } + + /** + * @throws ZipImportException + */ + protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void + { + $errors = []; + + // TODO - Extract messages to language files + // TODO - Ensure these are shown to users on failure + + $chapters = []; + $pages = []; + $images = []; + $attachments = []; + + if ($exportModel instanceof ZipExportBook) { + if (!userCan('book-create-all')) { + $errors[] = 'You are lacking the required permission to create books.'; + } + array_push($pages, ...$exportModel->pages); + array_push($chapters, ...$exportModel->chapters); + } else if ($exportModel instanceof ZipExportChapter) { + $chapters[] = $exportModel; + } else if ($exportModel instanceof ZipExportPage) { + $pages[] = $exportModel; + } + + foreach ($chapters as $chapter) { + array_push($pages, ...$chapter->pages); + } + + if (count($chapters) > 0) { + $permission = 'chapter-create' . ($parent ? '' : '-all'); + if (!userCan($permission, $parent)) { + $errors[] = 'You are lacking the required permission to create chapters.'; + } + } + + foreach ($pages as $page) { + array_push($attachments, ...$page->attachments); + array_push($images, ...$page->images); + } + + if (count($pages) > 0) { + if ($parent) { + if (!userCan('page-create', $parent)) { + $errors[] = 'You are lacking the required permission to create pages.'; + } + } else { + $hasPermission = userCan('page-create-all') || userCan('page-create-own'); + if (!$hasPermission) { + $errors[] = 'You are lacking the required permission to create pages.'; + } + } + } + + if (count($images) > 0) { + if (!userCan('image-create-all')) { + $errors[] = 'You are lacking the required permissions to create images.'; + } + } + + if (count($attachments) > 0) { + if (userCan('attachment-create-all')) { + $errors[] = 'You are lacking the required permissions to create attachments.'; + } + } + + if (count($errors)) { + throw new ZipImportException($errors); + } + } + + protected function getZipPath(Import $import): string + { + if (!$this->storage->isRemote()) { + return $this->storage->getSystemPath($import->path); + } + + $tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-'); + $tempFile = fopen($tempFilePath, 'wb'); + $stream = $this->storage->getReadStream($import->path); + stream_copy_to_stream($stream, $tempFile); + fclose($tempFile); + + return $tempFilePath; + } +} diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php index 278484e519d..e6ac368d000 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -5,6 +5,7 @@ use BookStack\Exceptions\FileUploadException; use Exception; use Illuminate\Contracts\Filesystem\Filesystem as Storage; +use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -70,6 +71,26 @@ public function uploadFile(UploadedFile $file, string $subDirectory, string $suf return $filePath; } + /** + * Check whether the configured storage is remote from the host of this app. + */ + public function isRemote(): bool + { + return $this->getStorageDiskName() === 's3'; + } + + /** + * Get the actual path on system for the given relative file path. + */ + public function getSystemPath(string $filePath): string + { + if ($this->isRemote()) { + return ''; + } + + return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/')); + } + /** * Get the storage that will be used for storing files. */ @@ -83,7 +104,7 @@ protected function getStorageDisk(): Storage */ protected function getStorageDiskName(): string { - $storageType = config('filesystems.attachments'); + $storageType = trim(strtolower(config('filesystems.attachments'))); // Change to our secure-attachment disk if any of the local options // are used to prevent escaping that location. From d13e4d2eefeed427c0377be04761a639e9fdb8fc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 9 Nov 2024 14:01:24 +0000 Subject: [PATCH 36/72] ZIP imports: Started actual import logic --- app/Entities/Tools/Cloner.php | 17 +-- .../ZipExports/Models/ZipExportAttachment.php | 6 +- .../ZipExports/Models/ZipExportTag.php | 6 +- app/Exports/ZipExports/ZipExportReader.php | 8 ++ app/Exports/ZipExports/ZipImportRunner.php | 107 ++++++++++++++++++ dev/docs/portable-zip-file-format.md | 4 +- 6 files changed, 124 insertions(+), 24 deletions(-) diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 2030b050c4b..2be6083e3dd 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -18,17 +18,12 @@ class Cloner { - protected PageRepo $pageRepo; - protected ChapterRepo $chapterRepo; - protected BookRepo $bookRepo; - protected ImageService $imageService; - - public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService) - { - $this->pageRepo = $pageRepo; - $this->chapterRepo = $chapterRepo; - $this->bookRepo = $bookRepo; - $this->imageService = $imageService; + public function __construct( + protected PageRepo $pageRepo, + protected ChapterRepo $chapterRepo, + protected BookRepo $bookRepo, + protected ImageService $imageService, + ) { } /** diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 1dbdc7333e8..c6615e1dc49 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -10,13 +10,12 @@ class ZipExportAttachment extends ZipExportModel { public ?int $id = null; public string $name; - public ?int $order = null; public ?string $link = null; public ?string $file = null; public function metadataOnly(): void { - $this->order = $this->link = $this->file = null; + $this->link = $this->file = null; } public static function fromModel(Attachment $model, ZipExportFiles $files): self @@ -24,7 +23,6 @@ public static function fromModel(Attachment $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->order = $model->order; if ($model->external) { $instance->link = $model->path; @@ -47,7 +45,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra $rules = [ 'id' => ['nullable', 'int'], 'name' => ['required', 'string', 'min:1'], - 'order' => ['nullable', 'integer'], 'link' => ['required_without:file', 'nullable', 'string'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; @@ -61,7 +58,6 @@ public static function fromArray(array $data): self $model->id = $data['id'] ?? null; $model->name = $data['name']; - $model->order = isset($data['order']) ? intval($data['order']) : null; $model->link = $data['link'] ?? null; $model->file = $data['file'] ?? null; diff --git a/app/Exports/ZipExports/Models/ZipExportTag.php b/app/Exports/ZipExports/Models/ZipExportTag.php index b6c9e338aef..6b4720fca7e 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -9,11 +9,10 @@ class ZipExportTag extends ZipExportModel { public string $name; public ?string $value = null; - public ?int $order = null; public function metadataOnly(): void { - $this->value = $this->order = null; + $this->value = null; } public static function fromModel(Tag $model): self @@ -21,7 +20,6 @@ public static function fromModel(Tag $model): self $instance = new self(); $instance->name = $model->name; $instance->value = $model->value; - $instance->order = $model->order; return $instance; } @@ -36,7 +34,6 @@ public static function validate(ZipValidationHelper $context, array $data): arra $rules = [ 'name' => ['required', 'string', 'min:1'], 'value' => ['nullable', 'string'], - 'order' => ['nullable', 'integer'], ]; return $context->validateData($data, $rules); @@ -48,7 +45,6 @@ public static function fromArray(array $data): self $model->name = $data['name']; $model->value = $data['value'] ?? null; - $model->order = isset($data['order']) ? intval($data['order']) : null; return $model; } diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index c3e47da048d..ebc2fbbc9e1 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -73,6 +73,14 @@ public function fileExists(string $fileName): bool return $this->zip->statName("files/{$fileName}") !== false; } + /** + * @return false|resource + */ + public function streamFile(string $fileName) + { + return $this->zip->getStream("files/{$fileName}"); + } + /** * @throws ZipExportException */ diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 2f784ebea6e..2b897ff9167 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -5,18 +5,33 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; +use BookStack\Entities\Repos\BookRepo; +use BookStack\Entities\Repos\ChapterRepo; +use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipImportException; use BookStack\Exports\Import; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportPage; +use BookStack\Exports\ZipExports\Models\ZipExportTag; use BookStack\Uploads\FileStorage; +use BookStack\Uploads\ImageService; +use Illuminate\Http\UploadedFile; class ZipImportRunner { + protected array $tempFilesToCleanup = []; // TODO + protected array $createdImages = []; // TODO + protected array $createdAttachments = []; // TODO + public function __construct( protected FileStorage $storage, + protected PageRepo $pageRepo, + protected ChapterRepo $chapterRepo, + protected BookRepo $bookRepo, + protected ImageService $imageService, ) { } @@ -51,6 +66,98 @@ public function run(Import $import, ?Entity $parent = null): void $this->ensurePermissionsPermitImport($exportModel); // TODO - Run import + // TODO - In transaction? + // TODO - Revert uploaded files if goes wrong + } + + protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book + { + $book = $this->bookRepo->create([ + 'name' => $exportBook->name, + 'description_html' => $exportBook->description_html ?? '', + 'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null, + 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), + ]); + + // TODO - Parse/format description_html references + + if ($book->cover) { + $this->createdImages[] = $book->cover; + } + + // TODO - Pages + foreach ($exportBook->chapters as $exportChapter) { + $this->importChapter($exportChapter, $book); + } + // TODO - Sort chapters/pages by order + + return $book; + } + + protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter + { + $chapter = $this->chapterRepo->create([ + 'name' => $exportChapter->name, + 'description_html' => $exportChapter->description_html ?? '', + 'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []), + ], $parent); + + // TODO - Parse/format description_html references + + $exportPages = $exportChapter->pages; + usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + foreach ($exportPages as $exportPage) { + // + } + // TODO - Pages + + return $chapter; + } + + protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page + { + $page = $this->pageRepo->getNewDraftPage($parent); + + // TODO - Import attachments + // TODO - Import images + // TODO - Parse/format HTML + + $this->pageRepo->publishDraft($page, [ + 'name' => $exportPage->name, + 'markdown' => $exportPage->markdown, + 'html' => $exportPage->html, + 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), + ]); + + return $page; + } + + protected function exportTagsToInputArray(array $exportTags): array + { + $tags = []; + + /** @var ZipExportTag $tag */ + foreach ($exportTags as $tag) { + $tags[] = ['name' => $tag->name, 'value' => $tag->value ?? '']; + } + + return $tags; + } + + protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile + { + $tempPath = tempnam(sys_get_temp_dir(), 'bszipextract'); + $fileStream = $reader->streamFile($fileName); + $tempStream = fopen($tempPath, 'wb'); + stream_copy_to_stream($fileStream, $tempStream); + fclose($tempStream); + + $this->tempFilesToCleanup[] = $tempPath; + + return new UploadedFile($tempPath, $fileName); } /** diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 6cee7356d23..7e5df3f015b 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -135,12 +135,10 @@ embedded within it. - `name` - String, required, name of attachment. - `link` - String, semi-optional, URL of attachment. - `file` - String reference, semi-optional, reference to attachment file. -- `order` - Number, optional, integer order of the attachments (shown low to high). Either `link` or `file` must be present, as that will determine the type of attachment. #### Tag - `name` - String, required, name of the tag. -- `value` - String, optional, value of the tag (can be empty). -- `order` - Number, optional, integer order of the tags (shown low to high). \ No newline at end of file +- `value` - String, optional, value of the tag (can be empty). \ No newline at end of file From f12946d581fe66d5269f992db6573bb892286303 Mon Sep 17 00:00:00 2001 From: czemu Date: Sun, 10 Nov 2024 09:39:33 +0100 Subject: [PATCH 37/72] ExportFormatter: Add book description and check for empty book and chapter descriptions in markdown export --- app/Entities/Tools/ExportFormatter.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index beddfe8e6e0..e85992a9d70 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -315,7 +315,11 @@ public function pageToMarkdown(Page $page): string public function chapterToMarkdown(Chapter $chapter): string { $text = '# ' . $chapter->name . "\n\n"; - $text .= $chapter->description . "\n\n"; + + if (!empty($chapter->description)) { + $text .= $chapter->description . "\n\n"; + } + foreach ($chapter->pages as $page) { $text .= $this->pageToMarkdown($page) . "\n\n"; } @@ -330,6 +334,11 @@ public function bookToMarkdown(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; + + if (!empty($book->description)) { + $text .= $book->description . "\n\n"; + } + foreach ($bookTree as $bookChild) { if ($bookChild instanceof Chapter) { $text .= $this->chapterToMarkdown($bookChild) . "\n\n"; From 378f0d595fe8aa5aca212e1c5ed22944bf8bf1b7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 10 Nov 2024 16:03:50 +0000 Subject: [PATCH 38/72] ZIP Imports: Built out reference parsing/updating logic --- app/Entities/Repos/PageRepo.php | 13 +- .../ZipExports/ZipExportReferences.php | 8 +- .../ZipExports/ZipImportReferences.php | 142 ++++++++++++++++++ app/Exports/ZipExports/ZipImportRunner.php | 20 ++- app/Exports/ZipExports/ZipReferenceParser.php | 72 +++++++-- 5 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 app/Exports/ZipExports/ZipImportReferences.php diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 1bc15392cec..68b1c398f80 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -87,6 +87,17 @@ public function publishDraft(Page $draft, array $input): Page return $draft; } + /** + * Directly update the content for the given page from the provided input. + * Used for direct content access in a way that performs required changes + * (Search index & reference regen) without performing an official update. + */ + public function setContentFromInput(Page $page, array $input): void + { + $this->updateTemplateStatusAndContentFromInput($page, $input); + $this->baseRepo->update($page, []); + } + /** * Update a page in the system. */ @@ -121,7 +132,7 @@ public function update(Page $page, array $input): Page return $page; } - protected function updateTemplateStatusAndContentFromInput(Page $page, array $input) + protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void { if (isset($input['template']) && userCan('templates-manage')) { $page->template = ($input['template'] === 'true'); diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index c630c832b37..0de409fa19a 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -85,9 +85,9 @@ public function buildReferences(ZipExportFiles $files): void // Parse page content first foreach ($this->pages as $page) { $handler = $createHandler($page); - $page->html = $this->parser->parse($page->html ?? '', $handler); + $page->html = $this->parser->parseLinks($page->html ?? '', $handler); if ($page->markdown) { - $page->markdown = $this->parser->parse($page->markdown, $handler); + $page->markdown = $this->parser->parseLinks($page->markdown, $handler); } } @@ -95,7 +95,7 @@ public function buildReferences(ZipExportFiles $files): void foreach ($this->chapters as $chapter) { if ($chapter->description_html) { $handler = $createHandler($chapter); - $chapter->description_html = $this->parser->parse($chapter->description_html, $handler); + $chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler); } } @@ -103,7 +103,7 @@ public function buildReferences(ZipExportFiles $files): void foreach ($this->books as $book) { if ($book->description_html) { $handler = $createHandler($book); - $book->description_html = $this->parser->parse($book->description_html, $handler); + $book->description_html = $this->parser->parseLinks($book->description_html, $handler); } } } diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php new file mode 100644 index 00000000000..8062886e542 --- /dev/null +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -0,0 +1,142 @@ + */ + protected array $referenceMap = []; + + /** @var array */ + protected array $zipExportPageMap = []; + /** @var array */ + protected array $zipExportChapterMap = []; + /** @var array */ + protected array $zipExportBookMap = []; + + public function __construct( + protected ZipReferenceParser $parser, + protected BaseRepo $baseRepo, + protected PageRepo $pageRepo, + protected ImageResizer $imageResizer, + ) { + } + + protected function addReference(string $type, Model $model, ?int $importId): void + { + if ($importId) { + $key = $type . ':' . $importId; + $this->referenceMap[$key] = $model; + } + } + + public function addPage(Page $page, ZipExportPage $exportPage): void + { + $this->pages[] = $page; + $this->zipExportPageMap[$page->id] = $exportPage; + $this->addReference('page', $page, $exportPage->id); + } + + public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void + { + $this->chapters[] = $chapter; + $this->zipExportChapterMap[$chapter->id] = $exportChapter; + $this->addReference('chapter', $chapter, $exportChapter->id); + } + + public function addBook(Book $book, ZipExportBook $exportBook): void + { + $this->books[] = $book; + $this->zipExportBookMap[$book->id] = $exportBook; + $this->addReference('book', $book, $exportBook->id); + } + + public function addAttachment(Attachment $attachment, ?int $importId): void + { + $this->attachments[] = $attachment; + $this->addReference('attachment', $attachment, $importId); + } + + public function addImage(Image $image, ?int $importId): void + { + $this->images[] = $image; + $this->addReference('image', $image, $importId); + } + + protected function handleReference(string $type, int $id): ?string + { + $key = $type . ':' . $id; + $model = $this->referenceMap[$key] ?? null; + if ($model instanceof Entity) { + return $model->getUrl(); + } else if ($model instanceof Image) { + if ($model->type === 'gallery') { + $this->imageResizer->loadGalleryThumbnailsForImage($model, false); + return $model->thumbs['gallery'] ?? $model->url; + } + + return $model->url; + } + + return null; + } + + public function replaceReferences(): void + { + foreach ($this->books as $book) { + $exportBook = $this->zipExportBookMap[$book->id]; + $content = $exportBook->description_html || ''; + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->baseRepo->update($book, [ + 'description_html' => $parsed, + ]); + } + + foreach ($this->chapters as $chapter) { + $exportChapter = $this->zipExportChapterMap[$chapter->id]; + $content = $exportChapter->description_html || ''; + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->baseRepo->update($chapter, [ + 'description_html' => $parsed, + ]); + } + + foreach ($this->pages as $page) { + $exportPage = $this->zipExportPageMap[$page->id]; + $contentType = $exportPage->markdown ? 'markdown' : 'html'; + $content = $exportPage->markdown ?: ($exportPage->html ?: ''); + $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); + + $this->pageRepo->setContentFromInput($page, [ + $contentType => $parsed, + ]); + } + } +} diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 2b897ff9167..345c22be153 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -23,8 +23,6 @@ class ZipImportRunner { protected array $tempFilesToCleanup = []; // TODO - protected array $createdImages = []; // TODO - protected array $createdAttachments = []; // TODO public function __construct( protected FileStorage $storage, @@ -32,6 +30,7 @@ public function __construct( protected ChapterRepo $chapterRepo, protected BookRepo $bookRepo, protected ImageService $imageService, + protected ZipImportReferences $references, ) { } @@ -68,6 +67,11 @@ public function run(Import $import, ?Entity $parent = null): void // TODO - Run import // TODO - In transaction? // TODO - Revert uploaded files if goes wrong + // TODO - Attachments + // TODO - Images + // (Both listed/stored in references) + + $this->references->replaceReferences(); } protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book @@ -82,15 +86,17 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader // TODO - Parse/format description_html references if ($book->cover) { - $this->createdImages[] = $book->cover; + $this->references->addImage($book->cover, null); } // TODO - Pages foreach ($exportBook->chapters as $exportChapter) { - $this->importChapter($exportChapter, $book); + $this->importChapter($exportChapter, $book, $reader); } // TODO - Sort chapters/pages by order + $this->references->addBook($book, $exportBook); + return $book; } @@ -114,6 +120,8 @@ protected function importChapter(ZipExportChapter $exportChapter, Book $parent, } // TODO - Pages + $this->references->addChapter($chapter, $exportChapter); + return $chapter; } @@ -122,7 +130,9 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z $page = $this->pageRepo->getNewDraftPage($parent); // TODO - Import attachments + // TODO - Add attachment references // TODO - Import images + // TODO - Add image references // TODO - Parse/format HTML $this->pageRepo->publishDraft($page, [ @@ -132,6 +142,8 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), ]); + $this->references->addPage($page, $exportPage); + return $page; } diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index da43d1b366b..5929383b4dd 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -15,27 +15,23 @@ class ZipReferenceParser { /** - * @var CrossLinkModelResolver[] + * @var CrossLinkModelResolver[]|null */ - protected array $modelResolvers; + protected ?array $modelResolvers = null; - public function __construct(EntityQueries $queries) - { - $this->modelResolvers = [ - new PagePermalinkModelResolver($queries->pages), - new PageLinkModelResolver($queries->pages), - new ChapterLinkModelResolver($queries->chapters), - new BookLinkModelResolver($queries->books), - new ImageModelResolver(), - new AttachmentModelResolver(), - ]; + public function __construct( + protected EntityQueries $queries + ) { } /** * Parse and replace references in the given content. + * Calls the handler for each model link detected and replaces the link + * with the handler return value if provided. + * Returns the resulting content with links replaced. * @param callable(Model):(string|null) $handler */ - public function parse(string $content, callable $handler): string + public function parseLinks(string $content, callable $handler): string { $escapedBase = preg_quote(url('/'), '/'); $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#()]/"; @@ -59,13 +55,43 @@ public function parse(string $content, callable $handler): string return $content; } + /** + * Parse and replace references in the given content. + * Calls the handler for each reference detected and replaces the link + * with the handler return value if provided. + * Returns the resulting content string with references replaced. + * @param callable(string $type, int $id):(string|null) $handler + */ + public function parseReferences(string $content, callable $handler): string + { + $referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/'; + $matches = []; + preg_match_all($referenceRegex, $content, $matches); + + if (count($matches) < 3) { + return $content; + } + + for ($i = 0; $i < count($matches[0]); $i++) { + $referenceText = $matches[0][$i]; + $type = strtolower($matches[1][$i]); + $id = intval($matches[2][$i]); + $result = $handler($type, $id); + if ($result !== null) { + $content = str_replace($referenceText, $result, $content); + } + } + + return $content; + } + /** * Attempt to resolve the given link to a model using the instance model resolvers. */ protected function linkToModel(string $link): ?Model { - foreach ($this->modelResolvers as $resolver) { + foreach ($this->getModelResolvers() as $resolver) { $model = $resolver->resolve($link); if (!is_null($model)) { return $model; @@ -74,4 +100,22 @@ protected function linkToModel(string $link): ?Model return null; } + + protected function getModelResolvers(): array + { + if (isset($this->modelResolvers)) { + return $this->modelResolvers; + } + + $this->modelResolvers = [ + new PagePermalinkModelResolver($this->queries->pages), + new PageLinkModelResolver($this->queries->pages), + new ChapterLinkModelResolver($this->queries->chapters), + new BookLinkModelResolver($this->queries->books), + new ImageModelResolver(), + new AttachmentModelResolver(), + ]; + + return $this->modelResolvers; + } } From 48c101aa7ab5b77781f4cd536b654d037b5aa55e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 11 Nov 2024 15:06:46 +0000 Subject: [PATCH 39/72] ZIP Imports: Finished off core import logic --- app/Exceptions/ZipImportException.php | 3 +- app/Exports/Controllers/ImportController.php | 11 +- app/Exports/ImportRepo.php | 6 +- .../ZipExports/ZipImportReferences.php | 4 +- app/Exports/ZipExports/ZipImportRunner.php | 117 +++++++++++++++--- 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/app/Exceptions/ZipImportException.php b/app/Exceptions/ZipImportException.php index 2403c514477..452365c6e88 100644 --- a/app/Exceptions/ZipImportException.php +++ b/app/Exceptions/ZipImportException.php @@ -7,6 +7,7 @@ class ZipImportException extends \Exception public function __construct( public array $errors ) { - parent::__construct(); + $message = "Import failed with errors:" . implode("\n", $this->errors); + parent::__construct($message); } } diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index ec5ac80808b..4d2c83090a3 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -79,18 +79,21 @@ public function run(int $id, Request $request) $import = $this->imports->findVisible($id); $parent = null; - if ($import->getType() === 'page' || $import->getType() === 'chapter') { + if ($import->type === 'page' || $import->type === 'chapter') { $data = $this->validate($request, [ 'parent' => ['required', 'string'] ]); $parent = $data['parent']; } - // TODO - Run import - // TODO - Validate again before - // TODO - Check permissions before (create for main item, create for children, create for related items [image, attachments]) + $entity = $this->imports->runImport($import, $parent); + if ($entity) { + $this->logActivity(ActivityType::IMPORT_RUN, $import); + return redirect($entity->getUrl()); + } // TODO - Redirect to result // TODO - Or redirect back with errors + return 'failed'; } /** diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index b94563545a4..d169d4845ab 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,9 +2,11 @@ namespace BookStack\Exports; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\ZipExportException; +use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; @@ -95,9 +97,9 @@ public function storeFromUpload(UploadedFile $file): Import } /** - * @throws ZipValidationException + * @throws ZipValidationException|ZipImportException */ - public function runImport(Import $import, ?string $parent = null) + public function runImport(Import $import, ?string $parent = null): ?Entity { $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index 8062886e542..3bce16bbb13 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -110,7 +110,7 @@ public function replaceReferences(): void { foreach ($this->books as $book) { $exportBook = $this->zipExportBookMap[$book->id]; - $content = $exportBook->description_html || ''; + $content = $exportBook->description_html ?? ''; $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); $this->baseRepo->update($book, [ @@ -120,7 +120,7 @@ public function replaceReferences(): void foreach ($this->chapters as $chapter) { $exportChapter = $this->zipExportChapterMap[$chapter->id]; - $content = $exportChapter->description_html || ''; + $content = $exportChapter->description_html ?? ''; $parsed = $this->parser->parseReferences($content, $this->handleReference(...)); $this->baseRepo->update($chapter, [ diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 345c22be153..9f19f03e2e5 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -12,17 +12,22 @@ use BookStack\Exceptions\ZipExportException; use BookStack\Exceptions\ZipImportException; use BookStack\Exports\Import; +use BookStack\Exports\ZipExports\Models\ZipExportAttachment; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; +use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\Models\ZipExportTag; +use BookStack\Uploads\Attachment; +use BookStack\Uploads\AttachmentService; use BookStack\Uploads\FileStorage; +use BookStack\Uploads\Image; use BookStack\Uploads\ImageService; use Illuminate\Http\UploadedFile; class ZipImportRunner { - protected array $tempFilesToCleanup = []; // TODO + protected array $tempFilesToCleanup = []; public function __construct( protected FileStorage $storage, @@ -30,14 +35,19 @@ public function __construct( protected ChapterRepo $chapterRepo, protected BookRepo $bookRepo, protected ImageService $imageService, + protected AttachmentService $attachmentService, protected ZipImportReferences $references, ) { } /** + * Run the import. + * Performs re-validation on zip, validation on parent provided, and permissions for importing + * the planned content, before running the import process. + * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): void + public function run(Import $import, ?Entity $parent = null): ?Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); @@ -63,8 +73,16 @@ public function run(Import $import, ?Entity $parent = null): void } $this->ensurePermissionsPermitImport($exportModel); + $entity = null; + + if ($exportModel instanceof ZipExportBook) { + $entity = $this->importBook($exportModel, $reader); + } else if ($exportModel instanceof ZipExportChapter) { + $entity = $this->importChapter($exportModel, $parent, $reader); + } else if ($exportModel instanceof ZipExportPage) { + $entity = $this->importPage($exportModel, $parent, $reader); + } - // TODO - Run import // TODO - In transaction? // TODO - Revert uploaded files if goes wrong // TODO - Attachments @@ -72,6 +90,23 @@ public function run(Import $import, ?Entity $parent = null): void // (Both listed/stored in references) $this->references->replaceReferences(); + + $reader->close(); + $this->cleanup(); + + dd('stop'); + + // TODO - Delete import/zip after import? + // Do this in parent repo? + + return $entity; + } + + protected function cleanup() + { + foreach ($this->tempFilesToCleanup as $file) { + unlink($file); + } } protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book @@ -83,17 +118,26 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), ]); - // TODO - Parse/format description_html references - if ($book->cover) { $this->references->addImage($book->cover, null); } - // TODO - Pages - foreach ($exportBook->chapters as $exportChapter) { - $this->importChapter($exportChapter, $book, $reader); + $children = [ + ...$exportBook->chapters, + ...$exportBook->pages, + ]; + + usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + foreach ($children as $child) { + if ($child instanceof ZipExportChapter) { + $this->importChapter($child, $book, $reader); + } else if ($child instanceof ZipExportPage) { + $this->importPage($child, $book, $reader); + } } - // TODO - Sort chapters/pages by order $this->references->addBook($book, $exportBook); @@ -108,17 +152,14 @@ protected function importChapter(ZipExportChapter $exportChapter, Book $parent, 'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []), ], $parent); - // TODO - Parse/format description_html references - $exportPages = $exportChapter->pages; usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) { return ($a->priority ?? 0) - ($b->priority ?? 0); }); foreach ($exportPages as $exportPage) { - // + $this->importPage($exportPage, $chapter, $reader); } - // TODO - Pages $this->references->addChapter($chapter, $exportChapter); @@ -129,11 +170,13 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z { $page = $this->pageRepo->getNewDraftPage($parent); - // TODO - Import attachments - // TODO - Add attachment references - // TODO - Import images - // TODO - Add image references - // TODO - Parse/format HTML + foreach ($exportPage->attachments as $exportAttachment) { + $this->importAttachment($exportAttachment, $page, $reader); + } + + foreach ($exportPage->images as $exportImage) { + $this->importImage($exportImage, $page, $reader); + } $this->pageRepo->publishDraft($page, [ 'name' => $exportPage->name, @@ -147,6 +190,40 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z return $page; } + protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment + { + if ($exportAttachment->file) { + $file = $this->zipFileToUploadedFile($exportAttachment->file, $reader); + $attachment = $this->attachmentService->saveNewUpload($file, $page->id); + $attachment->name = $exportAttachment->name; + $attachment->save(); + } else { + $attachment = $this->attachmentService->saveNewFromLink( + $exportAttachment->name, + $exportAttachment->link ?? '', + $page->id, + ); + } + + $this->references->addAttachment($attachment, $exportAttachment->id); + + return $attachment; + } + + protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image + { + $file = $this->zipFileToUploadedFile($exportImage->file, $reader); + $image = $this->imageService->saveNewFromUpload( + $file, + $exportImage->type, + $page->id, + ); + + $this->references->addImage($image, $exportImage->id); + + return $image; + } + protected function exportTagsToInputArray(array $exportTags): array { $tags = []; @@ -235,7 +312,7 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| } if (count($attachments) > 0) { - if (userCan('attachment-create-all')) { + if (!userCan('attachment-create-all')) { $errors[] = 'You are lacking the required permissions to create attachments.'; } } @@ -257,6 +334,8 @@ protected function getZipPath(Import $import): string stream_copy_to_stream($stream, $tempFile); fclose($tempFile); + $this->tempFilesToCleanup[] = $tempFilePath; + return $tempFilePath; } } From b7476a9e7fc27c27342a0a155ab256a93f19981e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 14 Nov 2024 15:59:15 +0000 Subject: [PATCH 40/72] ZIP Import: Finished base import process & error handling Added file creation reverting and DB rollback on error. Added error display on failed import. Extracted likely shown import form/error text to translation files. --- app/Exports/Controllers/ImportController.php | 25 +++---- app/Exports/ImportRepo.php | 26 ++++++- .../ZipExports/ZipImportReferences.php | 17 +++++ app/Exports/ZipExports/ZipImportRunner.php | 69 +++++++++++-------- app/Uploads/AttachmentService.php | 2 +- app/Uploads/ImageService.php | 12 +++- lang/en/entities.php | 3 + lang/en/errors.php | 6 ++ resources/views/exports/import-show.blade.php | 49 ++++++++----- .../views/exports/parts/import.blade.php | 4 +- 10 files changed, 145 insertions(+), 68 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 4d2c83090a3..d8dceed2f8e 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -4,7 +4,7 @@ namespace BookStack\Exports\Controllers; -use BookStack\Activity\ActivityType; +use BookStack\Exceptions\ZipImportException; use BookStack\Exceptions\ZipValidationException; use BookStack\Exports\ImportRepo; use BookStack\Http\Controller; @@ -48,12 +48,9 @@ public function upload(Request $request) try { $import = $this->imports->storeFromUpload($file); } catch (ZipValidationException $exception) { - session()->flash('validation_errors', $exception->errors); - return redirect('/import'); + return redirect('/import')->with('validation_errors', $exception->errors); } - $this->logActivity(ActivityType::IMPORT_CREATE, $import); - return redirect($import->getUrl()); } @@ -80,20 +77,20 @@ public function run(int $id, Request $request) $parent = null; if ($import->type === 'page' || $import->type === 'chapter') { + session()->setPreviousUrl($import->getUrl()); $data = $this->validate($request, [ - 'parent' => ['required', 'string'] + 'parent' => ['required', 'string'], ]); $parent = $data['parent']; } - $entity = $this->imports->runImport($import, $parent); - if ($entity) { - $this->logActivity(ActivityType::IMPORT_RUN, $import); - return redirect($entity->getUrl()); + try { + $entity = $this->imports->runImport($import, $parent); + } catch (ZipImportException $exception) { + return redirect($import->getUrl())->with('import_errors', $exception->errors); } - // TODO - Redirect to result - // TODO - Or redirect back with errors - return 'failed'; + + return redirect($entity->getUrl()); } /** @@ -104,8 +101,6 @@ public function delete(int $id) $import = $this->imports->findVisible($id); $this->imports->deleteImport($import); - $this->logActivity(ActivityType::IMPORT_DELETE, $import); - return redirect('/import'); } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index d169d4845ab..f72386c47bc 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -2,6 +2,7 @@ namespace BookStack\Exports; +use BookStack\Activity\ActivityType; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; @@ -14,8 +15,10 @@ use BookStack\Exports\ZipExports\ZipExportReader; use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Exports\ZipExports\ZipImportRunner; +use BookStack\Facades\Activity; use BookStack\Uploads\FileStorage; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\File\UploadedFile; class ImportRepo @@ -93,25 +96,42 @@ public function storeFromUpload(UploadedFile $file): Import $import->path = $path; $import->save(); + Activity::add(ActivityType::IMPORT_CREATE, $import); + return $import; } /** - * @throws ZipValidationException|ZipImportException + * @throws ZipImportException */ - public function runImport(Import $import, ?string $parent = null): ?Entity + public function runImport(Import $import, ?string $parent = null): Entity { $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; } - return $this->importer->run($import, $parentModel); + DB::beginTransaction(); + try { + $model = $this->importer->run($import, $parentModel); + } catch (ZipImportException $e) { + DB::rollBack(); + $this->importer->revertStoredFiles(); + throw $e; + } + + DB::commit(); + $this->deleteImport($import); + Activity::add(ActivityType::IMPORT_RUN, $import); + + return $model; } public function deleteImport(Import $import): void { $this->storage->delete($import->path); $import->delete(); + + Activity::add(ActivityType::IMPORT_DELETE, $import); } } diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index 3bce16bbb13..b23d5e72b15 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -139,4 +139,21 @@ public function replaceReferences(): void ]); } } + + + /** + * @return Image[] + */ + public function images(): array + { + return $this->images; + } + + /** + * @return Attachment[] + */ + public function attachments(): array + { + return $this->attachments; + } } diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 9f19f03e2e5..c5b9da31909 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -47,14 +47,17 @@ public function __construct( * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): ?Entity + public function run(Import $import, ?Entity $parent = null): Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); $errors = (new ZipExportValidator($reader))->validate(); if ($errors) { - throw new ZipImportException(["ZIP failed to validate"]); + throw new ZipImportException([ + trans('errors.import_validation_failed'), + ...$errors, + ]); } try { @@ -65,15 +68,14 @@ public function run(Import $import, ?Entity $parent = null): ?Entity // Validate parent type if ($exportModel instanceof ZipExportBook && ($parent !== null)) { - throw new ZipImportException(["Must not have a parent set for a Book import"]); - } else if ($exportModel instanceof ZipExportChapter && (!$parent instanceof Book)) { - throw new ZipImportException(["Parent book required for chapter import"]); + throw new ZipImportException(["Must not have a parent set for a Book import."]); + } else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) { + throw new ZipImportException(["Parent book required for chapter import."]); } else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) { - throw new ZipImportException(["Parent book or chapter required for page import"]); + throw new ZipImportException(["Parent book or chapter required for page import."]); } - $this->ensurePermissionsPermitImport($exportModel); - $entity = null; + $this->ensurePermissionsPermitImport($exportModel, $parent); if ($exportModel instanceof ZipExportBook) { $entity = $this->importBook($exportModel, $reader); @@ -81,32 +83,46 @@ public function run(Import $import, ?Entity $parent = null): ?Entity $entity = $this->importChapter($exportModel, $parent, $reader); } else if ($exportModel instanceof ZipExportPage) { $entity = $this->importPage($exportModel, $parent, $reader); + } else { + throw new ZipImportException(['No importable data found in import data.']); } - // TODO - In transaction? - // TODO - Revert uploaded files if goes wrong - // TODO - Attachments - // TODO - Images - // (Both listed/stored in references) - $this->references->replaceReferences(); $reader->close(); $this->cleanup(); - dd('stop'); + return $entity; + } - // TODO - Delete import/zip after import? - // Do this in parent repo? + /** + * Revert any files which have been stored during this import process. + * Considers files only, and avoids the database under the + * assumption that the database may already have been + * reverted as part of a transaction rollback. + */ + public function revertStoredFiles(): void + { + foreach ($this->references->images() as $image) { + $this->imageService->destroyFileAtPath($image->type, $image->path); + } - return $entity; + foreach ($this->references->attachments() as $attachment) { + if (!$attachment->external) { + $this->attachmentService->deleteFileInStorage($attachment); + } + } + + $this->cleanup(); } - protected function cleanup() + protected function cleanup(): void { foreach ($this->tempFilesToCleanup as $file) { unlink($file); } + + $this->tempFilesToCleanup = []; } protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book @@ -256,9 +272,6 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| { $errors = []; - // TODO - Extract messages to language files - // TODO - Ensure these are shown to users on failure - $chapters = []; $pages = []; $images = []; @@ -266,7 +279,7 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| if ($exportModel instanceof ZipExportBook) { if (!userCan('book-create-all')) { - $errors[] = 'You are lacking the required permission to create books.'; + $errors[] = trans('errors.import_perms_books'); } array_push($pages, ...$exportModel->pages); array_push($chapters, ...$exportModel->chapters); @@ -283,7 +296,7 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| if (count($chapters) > 0) { $permission = 'chapter-create' . ($parent ? '' : '-all'); if (!userCan($permission, $parent)) { - $errors[] = 'You are lacking the required permission to create chapters.'; + $errors[] = trans('errors.import_perms_chapters'); } } @@ -295,25 +308,25 @@ protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter| if (count($pages) > 0) { if ($parent) { if (!userCan('page-create', $parent)) { - $errors[] = 'You are lacking the required permission to create pages.'; + $errors[] = trans('errors.import_perms_pages'); } } else { $hasPermission = userCan('page-create-all') || userCan('page-create-own'); if (!$hasPermission) { - $errors[] = 'You are lacking the required permission to create pages.'; + $errors[] = trans('errors.import_perms_pages'); } } } if (count($images) > 0) { if (!userCan('image-create-all')) { - $errors[] = 'You are lacking the required permissions to create images.'; + $errors[] = trans('errors.import_perms_images'); } } if (count($attachments) > 0) { if (!userCan('attachment-create-all')) { - $errors[] = 'You are lacking the required permissions to create attachments.'; + $errors[] = trans('errors.import_perms_attachments'); } } diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index fa53c4ae499..033f2334104 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -151,7 +151,7 @@ public function deleteFile(Attachment $attachment) * Delete a file from the filesystem it sits on. * Cleans any empty leftover folders. */ - protected function deleteFileInStorage(Attachment $attachment): void + public function deleteFileInStorage(Attachment $attachment): void { $this->storage->delete($attachment->path); } diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index e501cc7b12d..5c455cf8633 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -153,11 +153,19 @@ public function getImageStream(Image $image): mixed */ public function destroy(Image $image): void { - $disk = $this->storage->getDisk($image->type); - $disk->destroyAllMatchingNameFromPath($image->path); + $this->destroyFileAtPath($image->type, $image->path); $image->delete(); } + /** + * Destroy the underlying image file at the given path. + */ + public function destroyFileAtPath(string $type, string $path): void + { + $disk = $this->storage->getDisk($type); + $disk->destroyAllMatchingNameFromPath($path); + } + /** * Delete gallery and drawings that are not within HTML content of pages or page revisions. * Checks based off of only the image name. diff --git a/lang/en/entities.php b/lang/en/entities.php index ae1c1e8d4cc..26a563a7eb5 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -52,6 +52,7 @@ 'import_pending_none' => 'No imports have been started.', 'import_continue' => 'Continue Import', 'import_continue_desc' => 'Review the content due to be imported from the uploaded ZIP file. When ready, run the import to add its contents to this system. The uploaded ZIP import file will be automatically removed on successful import.', + 'import_details' => 'Import Details', 'import_run' => 'Run Import', 'import_size' => ':size Import ZIP Size', 'import_uploaded_at' => 'Uploaded :relativeTime', @@ -60,6 +61,8 @@ 'import_location_desc' => 'Select a target location for your imported content. You\'ll need the relevant permissions to create within the location you choose.', 'import_delete_confirm' => 'Are you sure you want to delete this import?', 'import_delete_desc' => 'This will delete the uploaded import ZIP file, and cannot be undone.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/en/errors.php b/lang/en/errors.php index 3f2f303311e..ced80a32c1f 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -109,6 +109,12 @@ 'import_zip_cant_read' => 'Could not read ZIP file.', 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', + 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_perms_books' => 'You are lacking the required permissions to create books.', + 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', + 'import_perms_pages' => 'You are lacking the required permissions to create pages.', + 'import_perms_images' => 'You are lacking the required permissions to create images.', + 'import_perms_attachments' => 'You are lacking the required permission to create attachments.', // API errors 'api_no_authorization_found' => 'No authorization token found on the request', diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index 40867377fb0..e4f199aa20c 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -7,8 +7,19 @@

    {{ trans('entities.import_continue') }}

    {{ trans('entities.import_continue_desc') }}

    + @if(session()->has('import_errors')) +
    + +

    {{ trans('entities.import_errors_desc') }}

    + @foreach(session()->get('import_errors') ?? [] as $error) +

    {{ $error }}

    + @endforeach +
    +
    + @endif +
    - +
    @include('exports.parts.import-item', ['type' => $import->type, 'model' => $data]) @@ -34,32 +45,36 @@ @if($import->type === 'page' || $import->type === 'chapter')
    -

    {{ trans('entities.import_location_desc') }}

    +

    {{ trans('entities.import_location_desc') }}

    + @if($errors->has('parent')) +
    + @include('form.errors', ['name' => 'parent']) +
    + @endif @include('entities.selector', [ 'name' => 'parent', 'entityTypes' => $import->type === 'page' ? 'chapter,book' : 'book', 'entityPermission' => "{$import->type}-create", 'selectorSize' => 'compact small', ]) - @include('form.errors', ['name' => 'parent']) @endif - -
    - {{ trans('common.cancel') }} -
    - - +
    diff --git a/resources/views/exports/parts/import.blade.php b/resources/views/exports/parts/import.blade.php index fd53095a422..2f7659c469e 100644 --- a/resources/views/exports/parts/import.blade.php +++ b/resources/views/exports/parts/import.blade.php @@ -4,7 +4,7 @@ class="text-{{ $import->type }}">@icon($import->type) {{ $import->name }}
    -
    {{ $import->getSizeString() }}
    -
    @icon('time'){{ $import->created_at->diffForHumans() }}
    +
    {{ $import->getSizeString() }}
    +
    @icon('time'){{ $import->created_at->diffForHumans() }}
    \ No newline at end of file From 7681e32dca6cb7d06c2d196bf46239a41a86852c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2024 13:57:41 +0000 Subject: [PATCH 41/72] ZIP Imports: Added high level import run tests --- app/Exports/Controllers/ImportController.php | 4 +- database/factories/Exports/ImportFactory.php | 2 +- tests/Exports/ZipImportRunnerTest.php | 21 +++ tests/Exports/ZipImportTest.php | 133 +++++++++++++++++-- tests/Exports/ZipTestHelper.php | 47 +++++++ 5 files changed, 192 insertions(+), 15 deletions(-) create mode 100644 tests/Exports/ZipImportRunnerTest.php create mode 100644 tests/Exports/ZipTestHelper.php diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index d8dceed2f8e..a20c341fb02 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -70,9 +70,11 @@ public function show(int $id) ]); } + /** + * Run the import process against an uploaded import ZIP. + */ public function run(int $id, Request $request) { - // TODO - Test access/visibility $import = $this->imports->findVisible($id); $parent = null; diff --git a/database/factories/Exports/ImportFactory.php b/database/factories/Exports/ImportFactory.php index 74a2bcd65f3..5d0b4f89299 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -21,7 +21,7 @@ class ImportFactory extends Factory public function definition(): array { return [ - 'path' => 'uploads/imports/' . Str::random(10) . '.zip', + 'path' => 'uploads/files/imports/' . Str::random(10) . '.zip', 'name' => $this->faker->words(3, true), 'type' => 'book', 'metadata' => '{"name": "My book"}', diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php new file mode 100644 index 00000000000..7bdd8ecbb65 --- /dev/null +++ b/tests/Exports/ZipImportRunnerTest.php @@ -0,0 +1,21 @@ +runner = app()->make(ZipImportRunner::class); + } + + // TODO - Test full book import + // TODO - Test full chapter import + // TODO - Test full page import +} diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index 2b40100aabe..3644e9bdcb7 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -3,6 +3,7 @@ namespace Tests\Exports; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\Book; use BookStack\Exports\Import; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; @@ -91,7 +92,7 @@ public function test_error_shown_if_missing_data() public function test_error_shown_if_no_importable_key() { $this->asAdmin(); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ 'instance' => [] ])); @@ -103,7 +104,7 @@ public function test_error_shown_if_no_importable_key() public function test_zip_data_validation_messages_shown() { $this->asAdmin(); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ 'book' => [ 'id' => 4, 'pages' => [ @@ -154,7 +155,7 @@ public function test_import_upload_success() ], ]; - $resp = $this->runImportFromFile($this->zipUploadFromData($data)); + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData($data)); $this->assertDatabaseHas('imports', [ 'name' => 'My great book name', @@ -217,7 +218,7 @@ public function test_import_show_page_access_limited() public function test_import_delete() { $this->asAdmin(); - $this->runImportFromFile($this->zipUploadFromData([ + $this->runImportFromFile(ZipTestHelper::zipUploadFromData([ 'book' => [ 'name' => 'My great book name' ], @@ -262,20 +263,126 @@ public function test_import_delete_access_limited() $this->delete("/import/{$adminImport->id}")->assertRedirect('/import'); } - protected function runImportFromFile(UploadedFile $file): TestResponse + public function test_run_simple_success_scenario() { - return $this->call('POST', '/import', [], [], ['file' => $file]); + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'name' => 'My imported book', + 'pages' => [ + [ + 'name' => 'My imported book page', + 'html' => '

    Hello there from child page!

    ' + ] + ], + ] + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}"); + $book = Book::query()->where('name', '=', 'My imported book')->latest()->first(); + $resp->assertRedirect($book->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSee('My imported book page'); + $resp->assertSee('Hello there from child page!'); + + $this->assertDatabaseMissing('imports', ['id' => $import->id]); + $this->assertFileDoesNotExist(storage_path($import->path)); + $this->assertActivityExists(ActivityType::IMPORT_RUN, null, $import->logDescriptor()); } - protected function zipUploadFromData(array $data): UploadedFile + public function test_import_run_access_limited() { - $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + $user = $this->users->editor(); + $admin = $this->users->admin(); + $userImport = Import::factory()->create(['name' => 'MySuperUserImport', 'created_by' => $user->id]); + $adminImport = Import::factory()->create(['name' => 'MySuperAdminImport', 'created_by' => $admin->id]); + $this->actingAs($user); - $zip = new ZipArchive(); - $zip->open($zipFile, ZipArchive::CREATE); - $zip->addFromString('data.json', json_encode($data)); - $zip->close(); + $this->post("/import/{$userImport->id}")->assertRedirect('/'); + $this->post("/import/{$adminImport->id}")->assertRedirect('/'); + + $this->permissions->grantUserRolePermissions($user, ['content-import']); + + $this->post("/import/{$userImport->id}")->assertRedirect($userImport->getUrl()); // Getting validation response instead of access issue response + $this->post("/import/{$adminImport->id}")->assertStatus(404); + + $this->permissions->grantUserRolePermissions($user, ['settings-manage']); + + $this->post("/import/{$adminImport->id}")->assertRedirect($adminImport->getUrl()); // Getting validation response instead of access issue response + } + + public function test_run_revalidates_content() + { + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'id' => 'abc', + ] + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}"); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('The name field is required.'); + $resp->assertSeeText('The id must be an integer.'); + } + + public function test_run_checks_permissions_on_import() + { + $viewer = $this->users->viewer(); + $this->permissions->grantUserRolePermissions($viewer, ['content-import']); + $import = ZipTestHelper::importFromData(['created_by' => $viewer->id], [ + 'book' => ['name' => 'My import book'], + ]); + + $resp = $this->asViewer()->post("/import/{$import->id}"); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSeeText('You are lacking the required permissions to create books.'); + } + + public function test_run_requires_parent_for_chapter_and_page_imports() + { + $book = $this->entities->book(); + $pageImport = ZipTestHelper::importFromData([], [ + 'page' => ['name' => 'My page', 'html' => '

    page test!

    '], + ]); + $chapterImport = ZipTestHelper::importFromData([], [ + 'chapter' => ['name' => 'My chapter'], + ]); + + $resp = $this->asAdmin()->post("/import/{$pageImport->id}"); + $resp->assertRedirect($pageImport->getUrl()); + $this->followRedirects($resp)->assertSee('The parent field is required.'); + + $resp = $this->asAdmin()->post("/import/{$pageImport->id}", ['parent' => "book:{$book->id}"]); + $resp->assertRedirectContains($book->getUrl()); + + $resp = $this->asAdmin()->post("/import/{$chapterImport->id}"); + $resp->assertRedirect($chapterImport->getUrl()); + $this->followRedirects($resp)->assertSee('The parent field is required.'); + + $resp = $this->asAdmin()->post("/import/{$chapterImport->id}", ['parent' => "book:{$book->id}"]); + $resp->assertRedirectContains($book->getUrl()); + } + + public function test_run_validates_correct_parent_type() + { + $chapter = $this->entities->chapter(); + $import = ZipTestHelper::importFromData([], [ + 'chapter' => ['name' => 'My chapter'], + ]); + + $resp = $this->asAdmin()->post("/import/{$import->id}", ['parent' => "chapter:{$chapter->id}"]); + $resp->assertRedirect($import->getUrl()); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Parent book required for chapter import.'); + } - return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + protected function runImportFromFile(UploadedFile $file): TestResponse + { + return $this->call('POST', '/import', [], [], ['file' => $file]); } } diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php new file mode 100644 index 00000000000..3a9b3435454 --- /dev/null +++ b/tests/Exports/ZipTestHelper.php @@ -0,0 +1,47 @@ +create($importData); + $zip = static::zipUploadFromData($zipData); + rename($zip->getRealPath(), storage_path($import->path)); + + return $import; + } + + public static function deleteZipForImport(Import $import): void + { + $path = storage_path($import->path); + if (file_exists($path)) { + unlink($path); + } + } + + public static function zipUploadFromData(array $data): UploadedFile + { + $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); + + $zip = new ZipArchive(); + $zip->open($zipFile, ZipArchive::CREATE); + $zip->addFromString('data.json', json_encode($data)); + $zip->close(); + + return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); + } +} From 8645aeaa4a914c5ee7e0d07a9202b8812aefcafe Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2024 16:12:45 +0000 Subject: [PATCH 42/72] ZIP Imports: Started testing core import logic Fixed image size handling, and lack of attachment reference replacements during testing. --- .../ZipExports/ZipImportReferences.php | 4 +- app/Exports/ZipExports/ZipImportRunner.php | 4 + app/Uploads/ImageService.php | 5 +- tests/Exports/ZipImportRunnerTest.php | 152 ++++++++++++++++++ tests/Exports/ZipTestHelper.php | 11 +- 5 files changed, 170 insertions(+), 6 deletions(-) diff --git a/app/Exports/ZipExports/ZipImportReferences.php b/app/Exports/ZipExports/ZipImportReferences.php index b23d5e72b15..da0581df6f5 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -97,10 +97,12 @@ protected function handleReference(string $type, int $id): ?string } else if ($model instanceof Image) { if ($model->type === 'gallery') { $this->imageResizer->loadGalleryThumbnailsForImage($model, false); - return $model->thumbs['gallery'] ?? $model->url; + return $model->thumbs['display'] ?? $model->url; } return $model->url; + } else if ($model instanceof Attachment) { + return $model->getUrl(false); } return null; diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index c5b9da31909..27d859e5915 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -233,6 +233,10 @@ protected function importImage(ZipExportImage $exportImage, Page $page, ZipExpor $file, $exportImage->type, $page->id, + null, + null, + true, + $exportImage->name, ); $this->references->addImage($image, $exportImage->id); diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 5c455cf8633..038e6aa417c 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -33,9 +33,10 @@ public function saveNewFromUpload( int $uploadedTo = 0, int $resizeWidth = null, int $resizeHeight = null, - bool $keepRatio = true + bool $keepRatio = true, + string $imageName = '', ): Image { - $imageName = $uploadedFile->getClientOriginalName(); + $imageName = $imageName ?: $uploadedFile->getClientOriginalName(); $imageData = file_get_contents($uploadedFile->getRealPath()); if ($resizeWidth !== null || $resizeHeight !== null) { diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index 7bdd8ecbb65..f07b3f41b42 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -2,7 +2,10 @@ namespace Tests\Exports; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipImportRunner; +use BookStack\Uploads\Image; use Tests\TestCase; class ZipImportRunnerTest extends TestCase @@ -15,6 +18,155 @@ protected function setUp(): void $this->runner = app()->make(ZipImportRunner::class); } + public function test_book_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $import = ZipTestHelper::importFromData([], [ + 'book' => [ + 'id' => 5, + 'name' => 'Import test', + 'cover' => 'book_cover_image', + 'description_html' => '

    Link to chapter page

    ', + 'tags' => [ + ['name' => 'Animal', 'value' => 'Cat'], + ['name' => 'Category', 'value' => 'Test'], + ], + 'chapters' => [ + [ + 'id' => 6, + 'name' => 'Chapter A', + 'description_html' => '

    Link to book

    ', + 'priority' => 1, + 'tags' => [ + ['name' => 'Reviewed'], + ['name' => 'Category', 'value' => 'Test Chapter'], + ], + 'pages' => [ + [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => ' +

    Link to self

    +

    Link to cat image

    +

    Link to text attachment

    ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ], + [ + 'name' => 'Cats', + 'link' => 'https://example.com/cats', + ] + ], + 'images' => [ + [ + 'id' => 1, + 'name' => 'Cat', + 'type' => 'gallery', + 'file' => 'cat_image' + ], + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + ], + ], + [ + 'name' => 'Chapter child B', + 'priority' => 5, + ] + ], + 'pages' => [ + [ + 'name' => 'Page C', + 'markdown' => '[Link to text]([[bsexport:attachment:4]]?scale=big)', + 'priority' => 3, + ] + ], + ], + ], [ + 'book_cover_image' => $testImagePath, + 'file_attachment' => $testFilePath, + 'cat_image' => $testImagePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Book $book */ + $book = $this->runner->run($import); + + // Book checks + $this->assertEquals('Import test', $book->name); + $this->assertFileExists(public_path($book->cover->path)); + $this->assertCount(2, $book->tags); + $this->assertEquals('Cat', $book->tags()->first()->value); + $this->assertCount(2, $book->chapters); + $this->assertEquals(1, $book->directPages()->count()); + + // Chapter checks + $chapterA = $book->chapters()->where('name', 'Chapter A')->first(); + $this->assertCount(2, $chapterA->tags); + $firstChapterTag = $chapterA->tags()->first(); + $this->assertEquals('Reviewed', $firstChapterTag->name); + $this->assertEquals('', $firstChapterTag->value); + $this->assertCount(1, $chapterA->pages); + + // Page checks + /** @var Page $pageA */ + $pageA = $chapterA->pages->first(); + $this->assertEquals('Page A', $pageA->name); + $this->assertCount(1, $pageA->tags); + $firstPageTag = $pageA->tags()->first(); + $this->assertEquals('Unreviewed', $firstPageTag->name); + $this->assertCount(2, $pageA->attachments); + $firstAttachment = $pageA->attachments->first(); + $this->assertEquals('Text attachment', $firstAttachment->name); + $this->assertFileEquals($testFilePath, storage_path($firstAttachment->path)); + $this->assertFalse($firstAttachment->external); + $secondAttachment = $pageA->attachments->last(); + $this->assertEquals('Cats', $secondAttachment->name); + $this->assertEquals('https://example.com/cats', $secondAttachment->path); + $this->assertTrue($secondAttachment->external); + $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(2, $pageAImages); + $this->assertEquals('Cat', $pageAImages[0]->name); + $this->assertEquals('gallery', $pageAImages[0]->type); + $this->assertFileEquals($testImagePath, public_path($pageAImages[0]->path)); + $this->assertEquals('Dog Drawing', $pageAImages[1]->name); + $this->assertEquals('drawio', $pageAImages[1]->type); + + // Book order check + $children = $book->getDirectVisibleChildren()->values()->all(); + $this->assertEquals($children[0]->name, 'Chapter A'); + $this->assertEquals($children[1]->name, 'Page C'); + $this->assertEquals($children[2]->name, 'Chapter child B'); + + // Reference checks + $textAttachmentUrl = $firstAttachment->getUrl(); + $this->assertStringContainsString($pageA->getUrl(), $book->description_html); + $this->assertStringContainsString($book->getUrl(), $chapterA->description_html); + $this->assertStringContainsString($pageA->getUrl(), $pageA->html); + $this->assertStringContainsString($pageAImages[0]->getThumb(1680, null, true), $pageA->html); + $this->assertStringContainsString($firstAttachment->getUrl(), $pageA->html); + + // Reference in converted markdown + $pageC = $children[1]; + $this->assertStringContainsString("href=\"{$textAttachmentUrl}?scale=big\"", $pageC->html); + + ZipTestHelper::deleteZipForImport($import); + } + // TODO - Test full book import // TODO - Test full chapter import // TODO - Test full page import diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php index 3a9b3435454..2196f361c17 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -8,7 +8,7 @@ class ZipTestHelper { - public static function importFromData(array $importData, array $zipData): Import + public static function importFromData(array $importData, array $zipData, array $files = []): Import { if (isset($zipData['book'])) { $importData['type'] = 'book'; @@ -19,7 +19,7 @@ public static function importFromData(array $importData, array $zipData): Import } $import = Import::factory()->create($importData); - $zip = static::zipUploadFromData($zipData); + $zip = static::zipUploadFromData($zipData, $files); rename($zip->getRealPath(), storage_path($import->path)); return $import; @@ -33,13 +33,18 @@ public static function deleteZipForImport(Import $import): void } } - public static function zipUploadFromData(array $data): UploadedFile + public static function zipUploadFromData(array $data, array $files = []): UploadedFile { $zipFile = tempnam(sys_get_temp_dir(), 'bstest-'); $zip = new ZipArchive(); $zip->open($zipFile, ZipArchive::CREATE); $zip->addFromString('data.json', json_encode($data)); + + foreach ($files as $name => $file) { + $zip->addFile($file, "files/$name"); + } + $zip->close(); return new UploadedFile($zipFile, 'upload.zip', 'application/zip', null, true); From c2c64e207f89567350eab4b40b725e8d042c9654 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 16 Nov 2024 19:52:20 +0000 Subject: [PATCH 43/72] ZIP Imports: Covered import runner with further testing --- tests/Exports/ZipImportRunnerTest.php | 194 +++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 3 deletions(-) diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index f07b3f41b42..c833fadda96 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -3,6 +3,7 @@ namespace Tests\Exports; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Uploads\Image; @@ -167,7 +168,194 @@ public function test_book_import() ZipTestHelper::deleteZipForImport($import); } - // TODO - Test full book import - // TODO - Test full chapter import - // TODO - Test full page import + public function test_chapter_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->book(); + + $import = ZipTestHelper::importFromData([], [ + 'chapter' => [ + 'id' => 6, + 'name' => 'Chapter A', + 'description_html' => '

    Link to page

    ', + 'priority' => 1, + 'tags' => [ + ['name' => 'Reviewed', 'value' => '2024'], + ], + 'pages' => [ + [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => '

    Link to chapter

    +

    Link to dog drawing

    +

    Link to text attachment

    ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + [ + 'name' => 'Page B', + 'markdown' => '[Link to page A]([[bsexport:page:3]])', + 'priority' => 9, + ], + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Chapter $chapter */ + $chapter = $this->runner->run($import, $parent); + + // Chapter checks + $this->assertEquals('Chapter A', $chapter->name); + $this->assertEquals($parent->id, $chapter->book_id); + $this->assertCount(1, $chapter->tags); + $firstChapterTag = $chapter->tags()->first(); + $this->assertEquals('Reviewed', $firstChapterTag->name); + $this->assertEquals('2024', $firstChapterTag->value); + $this->assertCount(2, $chapter->pages); + + // Page checks + /** @var Page $pageA */ + $pageA = $chapter->pages->first(); + $this->assertEquals('Page A', $pageA->name); + $this->assertCount(1, $pageA->tags); + $this->assertCount(1, $pageA->attachments); + $pageAImages = Image::where('uploaded_to', '=', $pageA->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageAImages); + + // Reference checks + $attachment = $pageA->attachments->first(); + $this->assertStringContainsString($pageA->getUrl(), $chapter->description_html); + $this->assertStringContainsString($chapter->getUrl(), $pageA->html); + $this->assertStringContainsString($pageAImages[0]->url, $pageA->html); + $this->assertStringContainsString($attachment->getUrl(), $pageA->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_page_import() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'id' => 3, + 'name' => 'Page A', + 'priority' => 6, + 'html' => '

    Link to self

    +

    Link to dog drawing

    +

    Link to text attachment

    ', + 'tags' => [ + ['name' => 'Unreviewed'], + ], + 'attachments' => [ + [ + 'id' => 4, + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'id' => 2, + 'name' => 'Dog Drawing', + 'type' => 'drawio', + 'file' => 'dog_image' + ] + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + // Page checks + $this->assertEquals('Page A', $page->name); + $this->assertCount(1, $page->tags); + $this->assertCount(1, $page->attachments); + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + $this->assertCount(1, $pageImages); + $this->assertFileEquals($testImagePath, public_path($pageImages[0]->path)); + + // Reference checks + $this->assertStringContainsString($page->getUrl(), $page->html); + $this->assertStringContainsString($pageImages[0]->url, $page->html); + $this->assertStringContainsString($page->attachments->first()->getUrl(), $page->html); + + ZipTestHelper::deleteZipForImport($import); + } + + public function test_revert_cleans_up_uploaded_files() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $testFilePath = $this->files->testFilePath('test-file.txt'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

    Hello

    ', + 'attachments' => [ + [ + 'name' => 'Text attachment', + 'file' => 'file_attachment' + ] + ], + 'images' => [ + [ + 'name' => 'Dog Image', + 'type' => 'gallery', + 'file' => 'dog_image' + ] + ], + ], + ], [ + 'file_attachment' => $testFilePath, + 'dog_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $attachment = $page->attachments->first(); + $image = Image::query()->where('uploaded_to', '=', $page->id)->where('type', '=', 'gallery')->first(); + + $this->assertFileExists(public_path($image->path)); + $this->assertFileExists(storage_path($attachment->path)); + + $this->runner->revertStoredFiles(); + + $this->assertFileDoesNotExist(public_path($image->path)); + $this->assertFileDoesNotExist(storage_path($attachment->path)); + + ZipTestHelper::deleteZipForImport($import); + } } From e2f6e50df4347579e3b6eb8e7c48bfcb79199a64 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Nov 2024 15:53:21 +0000 Subject: [PATCH 44/72] ZIP Exports: Added ID checks and testing to validator --- .../ZipExports/Models/ZipExportAttachment.php | 2 +- .../ZipExports/Models/ZipExportBook.php | 2 +- .../ZipExports/Models/ZipExportChapter.php | 2 +- .../ZipExports/Models/ZipExportImage.php | 2 +- .../ZipExports/Models/ZipExportPage.php | 2 +- .../ZipExports/ZipFileReferenceRule.php | 1 - app/Exports/ZipExports/ZipUniqueIdRule.php | 26 +++++++ .../ZipExports/ZipValidationHelper.php | 24 ++++++ lang/en/validation.php | 1 + tests/Exports/ZipExportValidatorTests.php | 74 +++++++++++++++++++ 10 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 app/Exports/ZipExports/ZipUniqueIdRule.php create mode 100644 tests/Exports/ZipExportValidatorTests.php diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index c6615e1dc49..4f5b2f23699 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -43,7 +43,7 @@ public static function fromModelArray(array $attachmentArray, ZipExportFiles $fi public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], 'name' => ['required', 'string', 'min:1'], 'link' => ['required_without:file', 'nullable', 'string'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 0dc4e93d43c..47ab8f0a699 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -70,7 +70,7 @@ public static function fromModel(Book $model, ZipExportFiles $files): self public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('book')], 'name' => ['required', 'string', 'min:1'], 'description_html' => ['nullable', 'string'], 'cover' => ['nullable', 'string', $context->fileReferenceRule()], diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 50440d61a5a..5a5fe350f3a 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -59,7 +59,7 @@ public static function fromModelArray(array $chapterArray, ZipExportFiles $files public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')], 'name' => ['required', 'string', 'min:1'], 'description_html' => ['nullable', 'string'], 'priority' => ['nullable', 'int'], diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 691eb918fc6..89083b15be1 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -33,7 +33,7 @@ public function metadataOnly(): void public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('image')], 'name' => ['required', 'string', 'min:1'], 'file' => ['required', 'string', $context->fileReferenceRule()], 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 3a876e7aaff..16e7e925539 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -68,7 +68,7 @@ public static function fromModelArray(array $pageArray, ZipExportFiles $files): public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ - 'id' => ['nullable', 'int'], + 'id' => ['nullable', 'int', $context->uniqueIdRule('page')], 'name' => ['required', 'string', 'min:1'], 'html' => ['nullable', 'string'], 'markdown' => ['nullable', 'string'], diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index bcd3c39acf0..7d6c829cf03 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -4,7 +4,6 @@ use Closure; use Illuminate\Contracts\Validation\ValidationRule; -use ZipArchive; class ZipFileReferenceRule implements ValidationRule { diff --git a/app/Exports/ZipExports/ZipUniqueIdRule.php b/app/Exports/ZipExports/ZipUniqueIdRule.php new file mode 100644 index 00000000000..ea2b2539296 --- /dev/null +++ b/app/Exports/ZipExports/ZipUniqueIdRule.php @@ -0,0 +1,26 @@ +context->hasIdBeenUsed($this->modelType, $value)) { + $fail('validation.zip_unique')->translate(['attribute' => $attribute]); + } + } +} diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index 55c86b03b5b..7659c228bcd 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -9,6 +9,13 @@ class ZipValidationHelper { protected Factory $validationFactory; + /** + * Local store of validated IDs (in format ":". Example: "book:2") + * which we can use to check uniqueness. + * @var array + */ + protected array $validatedIds = []; + public function __construct( public ZipExportReader $zipReader, ) { @@ -31,6 +38,23 @@ public function fileReferenceRule(): ZipFileReferenceRule return new ZipFileReferenceRule($this); } + public function uniqueIdRule(string $type): ZipUniqueIdRule + { + return new ZipUniqueIdRule($this, $type); + } + + public function hasIdBeenUsed(string $type, int $id): bool + { + $key = $type . ':' . $id; + if (isset($this->validatedIds[$key])) { + return true; + } + + $this->validatedIds[$key] = true; + + return false; + } + /** * Validate an array of relation data arrays that are expected * to be for the given ZipExportModel. diff --git a/lang/en/validation.php b/lang/en/validation.php index bc01ac47b94..fdfc3d9a9b9 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -107,6 +107,7 @@ 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', 'zip_model_expected' => 'Data object expected but ":type" found.', + 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', // Custom validation lines 'custom' => [ diff --git a/tests/Exports/ZipExportValidatorTests.php b/tests/Exports/ZipExportValidatorTests.php new file mode 100644 index 00000000000..4cacea95ec6 --- /dev/null +++ b/tests/Exports/ZipExportValidatorTests.php @@ -0,0 +1,74 @@ +filesToRemove as $file) { + unlink($file); + } + + parent::tearDown(); + } + + protected function getValidatorForData(array $zipData, array $files = []): ZipExportValidator + { + $upload = ZipTestHelper::zipUploadFromData($zipData, $files); + $path = $upload->getRealPath(); + $this->filesToRemove[] = $path; + $reader = new ZipExportReader($path); + return new ZipExportValidator($reader); + } + + public function test_ids_have_to_be_unique() + { + $validator = $this->getValidatorForData([ + 'book' => [ + 'id' => 4, + 'name' => 'My book', + 'pages' => [ + [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'attachments' => [ + ['id' => 4, 'name' => 'Attachment A', 'link' => 'https://example.com'], + ['id' => 4, 'name' => 'Attachment B', 'link' => 'https://example.com'] + ], + 'images' => [ + ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'], + ['id' => 4, 'name' => 'Image b', 'type' => 'gallery', 'file' => 'cat'], + ], + ], + ['id' => 4, 'name' => 'My page', 'markdown' => 'hello'], + ], + 'chapters' => [ + ['id' => 4, 'name' => 'Chapter 1'], + ['id' => 4, 'name' => 'Chapter 2'] + ] + ] + ], ['cat' => $this->files->testFilePath('test-image.png')]); + + $results = $validator->validate(); + $this->assertCount(4, $results); + + $expectedMessage = 'The id must be unique for the object type within the ZIP.'; + $this->assertEquals($expectedMessage, $results['book.pages.0.attachments.1.id']); + $this->assertEquals($expectedMessage, $results['book.pages.0.images.1.id']); + $this->assertEquals($expectedMessage, $results['book.pages.1.id']); + $this->assertEquals($expectedMessage, $results['book.chapters.1.id']); + } +} From 59cfc087e12c8752b4a9f1760db71a13ad6c121c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 18 Nov 2024 17:42:49 +0000 Subject: [PATCH 45/72] ZIP Imports: Added image type validation/handling Images were missing their extension after import since it was (potentially) not part of the import data. This adds validation via mime sniffing (to match normal image upload checks) and also uses the same logic to sniff out a correct extension. Added tests to cover. Also fixed some existing tests around zip functionality. --- .../ZipExports/Models/ZipExportImage.php | 3 +- app/Exports/ZipExports/ZipExportReader.php | 12 +++++++ .../ZipExports/ZipFileReferenceRule.php | 12 +++++++ app/Exports/ZipExports/ZipImportRunner.php | 8 ++++- .../ZipExports/ZipValidationHelper.php | 6 ++-- lang/en/validation.php | 1 + tests/Exports/ZipExportTest.php | 4 --- ...orTests.php => ZipExportValidatorTest.php} | 21 ++++++++++- tests/Exports/ZipImportRunnerTest.php | 35 +++++++++++++++++++ 9 files changed, 92 insertions(+), 10 deletions(-) rename tests/Exports/{ZipExportValidatorTests.php => ZipExportValidatorTest.php} (77%) diff --git a/app/Exports/ZipExports/Models/ZipExportImage.php b/app/Exports/ZipExports/Models/ZipExportImage.php index 89083b15be1..e0e7d11986d 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -32,10 +32,11 @@ public function metadataOnly(): void public static function validate(ZipValidationHelper $context, array $data): array { + $acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('image')], 'name' => ['required', 'string', 'min:1'], - 'file' => ['required', 'string', $context->fileReferenceRule()], + 'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)], 'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])], ]; diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index ebc2fbbc9e1..6b88ef61c33 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -7,6 +7,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter; use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; +use BookStack\Util\WebSafeMimeSniffer; use ZipArchive; class ZipExportReader @@ -81,6 +82,17 @@ public function streamFile(string $fileName) return $this->zip->getStream("files/{$fileName}"); } + /** + * Sniff the mime type from the file of given name. + */ + public function sniffFileMime(string $fileName): string + { + $stream = $this->streamFile($fileName); + $sniffContent = fread($stream, 2000); + + return (new WebSafeMimeSniffer())->sniff($sniffContent); + } + /** * @throws ZipExportException */ diff --git a/app/Exports/ZipExports/ZipFileReferenceRule.php b/app/Exports/ZipExports/ZipFileReferenceRule.php index 7d6c829cf03..90e78c060b0 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -9,6 +9,7 @@ class ZipFileReferenceRule implements ValidationRule { public function __construct( protected ZipValidationHelper $context, + protected array $acceptedMimes, ) { } @@ -21,5 +22,16 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if (!$this->context->zipReader->fileExists($value)) { $fail('validation.zip_file')->translate(); } + + if (!empty($this->acceptedMimes)) { + $fileMime = $this->context->zipReader->sniffFileMime($value); + if (!in_array($fileMime, $this->acceptedMimes)) { + $fail('validation.zip_file_mime')->translate([ + 'attribute' => $attribute, + 'validTypes' => implode(',', $this->acceptedMimes), + 'foundType' => $fileMime + ]); + } + } } } diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 27d859e5915..d25a1621f6e 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -228,6 +228,9 @@ protected function importAttachment(ZipExportAttachment $exportAttachment, Page protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image { + $mime = $reader->sniffFileMime($exportImage->file); + $extension = explode('/', $mime)[1]; + $file = $this->zipFileToUploadedFile($exportImage->file, $reader); $image = $this->imageService->saveNewFromUpload( $file, @@ -236,9 +239,12 @@ protected function importImage(ZipExportImage $exportImage, Page $page, ZipExpor null, null, true, - $exportImage->name, + $exportImage->name . '.' . $extension, ); + $image->name = $exportImage->name; + $image->save(); + $this->references->addImage($image, $exportImage->id); return $image; diff --git a/app/Exports/ZipExports/ZipValidationHelper.php b/app/Exports/ZipExports/ZipValidationHelper.php index 7659c228bcd..fd9cd784472 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -33,9 +33,9 @@ public function validateData(array $data, array $rules): array return $messages; } - public function fileReferenceRule(): ZipFileReferenceRule + public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule { - return new ZipFileReferenceRule($this); + return new ZipFileReferenceRule($this, $acceptedMimes); } public function uniqueIdRule(string $type): ZipUniqueIdRule @@ -43,7 +43,7 @@ public function uniqueIdRule(string $type): ZipUniqueIdRule return new ZipUniqueIdRule($this, $type); } - public function hasIdBeenUsed(string $type, int $id): bool + public function hasIdBeenUsed(string $type, mixed $id): bool { $key = $type . ':' . $id; if (isset($this->validatedIds[$key])) { diff --git a/lang/en/validation.php b/lang/en/validation.php index fdfc3d9a9b9..d9b982d1e23 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,6 +106,7 @@ 'uploaded' => 'The file could not be uploaded. The server may not accept files of this size.', 'zip_file' => 'The :attribute needs to reference a file within the ZIP.', + 'zip_file_mime' => 'The :attribute needs to reference a file of type :validTypes, found :foundType.', 'zip_model_expected' => 'Data object expected but ":type" found.', 'zip_unique' => 'The :attribute must be unique for the object type within the ZIP.', diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index ac07b33aef5..12531239ff3 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -107,12 +107,10 @@ public function test_page_export_with_tags() [ 'name' => 'Exporty', 'value' => 'Content', - 'order' => 1, ], [ 'name' => 'Another', 'value' => '', - 'order' => 2, ] ], $pageData['tags']); } @@ -162,7 +160,6 @@ public function test_page_export_file_attachments() $attachmentData = $pageData['attachments'][0]; $this->assertEquals('PageAttachmentExport.txt', $attachmentData['name']); $this->assertEquals($attachment->id, $attachmentData['id']); - $this->assertEquals(1, $attachmentData['order']); $this->assertArrayNotHasKey('link', $attachmentData); $this->assertNotEmpty($attachmentData['file']); @@ -193,7 +190,6 @@ public function test_page_export_link_attachments() $attachmentData = $pageData['attachments'][0]; $this->assertEquals('My link attachment for export', $attachmentData['name']); $this->assertEquals($attachment->id, $attachmentData['id']); - $this->assertEquals(1, $attachmentData['order']); $this->assertEquals('https://example.com/cats', $attachmentData['link']); $this->assertArrayNotHasKey('file', $attachmentData); } diff --git a/tests/Exports/ZipExportValidatorTests.php b/tests/Exports/ZipExportValidatorTest.php similarity index 77% rename from tests/Exports/ZipExportValidatorTests.php rename to tests/Exports/ZipExportValidatorTest.php index 4cacea95ec6..c453ef294d4 100644 --- a/tests/Exports/ZipExportValidatorTests.php +++ b/tests/Exports/ZipExportValidatorTest.php @@ -11,7 +11,7 @@ use BookStack\Uploads\Image; use Tests\TestCase; -class ZipExportValidatorTests extends TestCase +class ZipExportValidatorTest extends TestCase { protected array $filesToRemove = []; @@ -71,4 +71,23 @@ public function test_ids_have_to_be_unique() $this->assertEquals($expectedMessage, $results['book.pages.1.id']); $this->assertEquals($expectedMessage, $results['book.chapters.1.id']); } + + public function test_image_files_need_to_be_a_valid_detected_image_file() + { + $validator = $this->getValidatorForData([ + 'page' => [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'images' => [ + ['id' => 4, 'name' => 'Image A', 'type' => 'gallery', 'file' => 'cat'], + ], + ] + ], ['cat' => $this->files->testFilePath('test-file.txt')]); + + $results = $validator->validate(); + $this->assertCount(1, $results); + + $this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']); + } } diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index c833fadda96..d3af6df76a6 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -358,4 +358,39 @@ public function test_revert_cleans_up_uploaded_files() ZipTestHelper::deleteZipForImport($import); } + + public function test_imported_images_have_their_detected_extension_added() + { + $testImagePath = $this->files->testFilePath('test-image.png'); + $parent = $this->entities->chapter(); + + $import = ZipTestHelper::importFromData([], [ + 'page' => [ + 'name' => 'Page A', + 'html' => '

    hello

    ', + 'images' => [ + [ + 'id' => 2, + 'name' => 'Cat', + 'type' => 'gallery', + 'file' => 'cat_image' + ] + ], + ], + ], [ + 'cat_image' => $testImagePath, + ]); + + $this->asAdmin(); + /** @var Page $page */ + $page = $this->runner->run($import, $parent); + + $pageImages = Image::where('uploaded_to', '=', $page->id)->whereIn('type', ['gallery', 'drawio'])->get(); + + $this->assertCount(1, $pageImages); + $this->assertStringEndsWith('.png', $pageImages[0]->url); + $this->assertStringEndsWith('.png', $pageImages[0]->path); + + ZipTestHelper::deleteZipForImport($import); + } } From c0dff6d4a6227549e7f756cdd0d7cd6a003b9886 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Nov 2024 21:03:04 +0000 Subject: [PATCH 46/72] ZIP Imports: Added book content ordering to import preview --- app/Exports/ZipExports/Models/ZipExportBook.php | 14 ++++++++++++++ app/Exports/ZipExports/Models/ZipExportChapter.php | 7 ++++++- app/Exports/ZipExports/Models/ZipExportPage.php | 2 +- app/Exports/ZipExports/ZipExportReader.php | 1 - .../views/exports/parts/import-item.blade.php | 14 ++++++++------ 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 47ab8f0a699..4f641d25bd7 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -36,6 +36,20 @@ public function metadataOnly(): void } } + public function children(): array + { + $children = [ + ...$this->pages, + ...$this->chapters, + ]; + + usort($children, function ($a, $b) { + return ($a->priority ?? 0) - ($b->priority ?? 0); + }); + + return $children; + } + public static function fromModel(Book $model, ZipExportFiles $files): self { $instance = new self(); diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 5a5fe350f3a..bf2dc78f8de 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -20,7 +20,7 @@ class ZipExportChapter extends ZipExportModel public function metadataOnly(): void { - $this->description_html = $this->priority = null; + $this->description_html = null; foreach ($this->pages as $page) { $page->metadataOnly(); @@ -30,6 +30,11 @@ public function metadataOnly(): void } } + public function children(): array + { + return $this->pages; + } + public static function fromModel(Chapter $model, ZipExportFiles $files): self { $instance = new self(); diff --git a/app/Exports/ZipExports/Models/ZipExportPage.php b/app/Exports/ZipExports/Models/ZipExportPage.php index 16e7e925539..097443df02b 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -23,7 +23,7 @@ class ZipExportPage extends ZipExportModel public function metadataOnly(): void { - $this->html = $this->markdown = $this->priority = null; + $this->html = $this->markdown = null; foreach ($this->attachments as $attachment) { $attachment->metadataOnly(); diff --git a/app/Exports/ZipExports/ZipExportReader.php b/app/Exports/ZipExports/ZipExportReader.php index 6b88ef61c33..c3d5c23cfec 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -5,7 +5,6 @@ use BookStack\Exceptions\ZipExportException; use BookStack\Exports\ZipExports\Models\ZipExportBook; use BookStack\Exports\ZipExports\Models\ZipExportChapter; -use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Util\WebSafeMimeSniffer; use ZipArchive; diff --git a/resources/views/exports/parts/import-item.blade.php b/resources/views/exports/parts/import-item.blade.php index 811a3b31bda..5da4b21405d 100644 --- a/resources/views/exports/parts/import-item.blade.php +++ b/resources/views/exports/parts/import-item.blade.php @@ -16,11 +16,13 @@ @icon('tag'){{ count($model->tags) }} @endif
    - @foreach($model->chapters ?? [] as $chapter) - @include('exports.parts.import-item', ['type' => 'chapter', 'model' => $chapter]) - @endforeach - @foreach($model->pages ?? [] as $page) - @include('exports.parts.import-item', ['type' => 'page', 'model' => $page]) - @endforeach + @if(method_exists($model, 'children')) + @foreach($model->children() as $child) + @include('exports.parts.import-item', [ + 'type' => ($child instanceof \BookStack\Exports\ZipExports\Models\ZipExportPage) ? 'page' : 'chapter', + 'model' => $child + ]) + @endforeach + @endif
    \ No newline at end of file From f79c6aef8d1c9aa83e9ce89ec1e5ac9d9e1eb570 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 22 Nov 2024 21:36:42 +0000 Subject: [PATCH 47/72] ZIP Imports: Updated import form to show loading indicator And disable button after submit. Added here because the import could take some time, so it's best to show an indicator to the user to show that something is happening, and help prevent duplicate submission or re-submit attempts. --- resources/js/components/index.js | 1 + resources/js/components/loading-button.ts | 38 +++++++++++++++++++ resources/sass/styles.scss | 4 ++ resources/views/exports/import-show.blade.php | 4 +- 4 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/loading-button.ts diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 8ad5e14cb2e..12c991a51d8 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -30,6 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; +export {LoadingButton} from './loading-button'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; diff --git a/resources/js/components/loading-button.ts b/resources/js/components/loading-button.ts new file mode 100644 index 00000000000..a793d30a235 --- /dev/null +++ b/resources/js/components/loading-button.ts @@ -0,0 +1,38 @@ +import {Component} from "./component.js"; +import {showLoading} from "../services/dom"; +import {el} from "../wysiwyg/utils/dom"; + +/** + * Loading button. + * Shows a loading indicator and disables the button when the button is clicked, + * or when the form attached to the button is submitted. + */ +export class LoadingButton extends Component { + + protected button!: HTMLButtonElement; + protected loadingEl: HTMLDivElement|null = null; + + setup() { + this.button = this.$el as HTMLButtonElement; + const form = this.button.form; + + const action = () => { + setTimeout(() => this.showLoadingState(), 10) + }; + + this.button.addEventListener('click', action); + if (form) { + form.addEventListener('submit', action); + } + } + + showLoadingState() { + this.button.disabled = true; + + if (!this.loadingEl) { + this.loadingEl = el('div', {class: 'inline block'}) as HTMLDivElement; + showLoading(this.loadingEl); + this.button.after(this.loadingEl); + } + } +} \ No newline at end of file diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 2cf3cbf8221..2106f86e625 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -106,6 +106,10 @@ $loadingSize: 10px; } } +.inline.block .loading-container { + margin: $-xs $-s; +} + .skip-to-content-link { position: fixed; top: -52px; diff --git a/resources/views/exports/import-show.blade.php b/resources/views/exports/import-show.blade.php index e4f199aa20c..a28b79bb35c 100644 --- a/resources/views/exports/import-show.blade.php +++ b/resources/views/exports/import-show.blade.php @@ -59,7 +59,7 @@ ]) @endif -
    +
    {{ trans('common.cancel') }}
    - +
    From 9ecc91929a60a66ff7e821dfbc9d2b55197988f7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Nov 2024 15:54:15 +0000 Subject: [PATCH 48/72] ZIP Import & Exports: Addressed issues during testing - Handled links to within-zip page images found in chapter/book descriptions; Added test to cover. - Fixed session showing unrelated success on failed import. Tested import file-create undo on failure as part of this testing. --- app/Exports/Controllers/ImportController.php | 2 ++ .../ZipExports/ZipExportReferences.php | 7 ++--- lang/en/errors.php | 1 + tests/Exports/ZipExportTest.php | 26 +++++++++++++++++++ 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index a20c341fb02..b938dac8e29 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -89,6 +89,8 @@ public function run(int $id, Request $request) try { $entity = $this->imports->runImport($import, $parent); } catch (ZipImportException $exception) { + session()->flush(); + $this->showErrorNotification(trans('errors.import_zip_failed_notification')); return redirect($import->getUrl())->with('import_errors', $exception->errors); } diff --git a/app/Exports/ZipExports/ZipExportReferences.php b/app/Exports/ZipExports/ZipExportReferences.php index 0de409fa19a..bf5e02133ef 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -127,11 +127,12 @@ protected function handleModelReference(Model $model, ZipExportModel $exportMode return null; } - // We don't expect images to be part of book/chapter content - if (!($exportModel instanceof ZipExportPage)) { - return null; + // Handle simple links outside of page content + if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) { + return "[[bsexport:image:{$model->id}]]"; } + // Find and include images if in visibility $page = $model->getPage(); if ($page && userCan('view', $page)) { if (!isset($this->images[$model->id])) { diff --git a/lang/en/errors.php b/lang/en/errors.php index ced80a32c1f..9d738379648 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -110,6 +110,7 @@ 'import_zip_cant_decode_data' => 'Could not find and decode ZIP data.json content.', 'import_zip_no_data' => 'ZIP file data has no expected book, chapter or page content.', 'import_validation_failed' => 'Import ZIP failed to validate with errors:', + 'import_zip_failed_notification' => 'Failed to import ZIP file.', 'import_perms_books' => 'You are lacking the required permissions to create books.', 'import_perms_chapters' => 'You are lacking the required permissions to create chapters.', 'import_perms_pages' => 'You are lacking the required permissions to create pages.', diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 12531239ff3..6e8462f596f 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -274,6 +274,32 @@ public function test_cross_reference_links_are_converted() $this->assertStringContainsString('href="[[bsexport:book:' . $book->id . ']]?view=true"', $pageData['html']); } + public function test_book_and_chapter_description_links_to_images_in_pages_are_converted() + { + $book = $this->entities->bookHasChaptersAndPages(); + $chapter = $book->chapters()->first(); + $page = $chapter->pages()->first(); + + $this->asEditor(); + $this->files->uploadGalleryImageToPage($this, $page); + /** @var Image $image */ + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id)->first(); + + $book->description_html = '

    Link to image

    '; + $book->save(); + $chapter->description_html = '

    Link to image

    '; + $chapter->save(); + + $zipResp = $this->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $bookData = $zip->data['book']; + $chapterData = $bookData['chapters'][0]; + + $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $bookData['description_html']); + $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']); + } + public function test_cross_reference_links_external_to_export_are_not_converted() { $page = $this->entities->page(); From 95d62e7f573b5bbb04a66fae926c8a33c6ab5c43 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Nov 2024 16:23:59 +0000 Subject: [PATCH 49/72] ZIP Imports/Exports: Fixed some lint and test issues - Updated test handling to create imports folder when required. - Updated some tests to delete created import zip files. --- resources/js/components/index.js | 2 +- resources/js/components/page-comments.js | 1 - tests/Exports/ZipImportTest.php | 8 ++++++++ tests/Exports/ZipTestHelper.php | 9 ++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 12c991a51d8..24e60bd97f2 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -30,7 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; -export {LoadingButton} from './loading-button'; +export {LoadingButton} from './loading-button.ts'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js index 1d6abfe2044..63900888aca 100644 --- a/resources/js/components/page-comments.js +++ b/resources/js/components/page-comments.js @@ -93,7 +93,6 @@ export class PageComments extends Component { updateCount() { const count = this.getCommentCount(); - console.log('update count', count, this.container); this.commentsTitle.textContent = window.$trans.choice(this.countText, count, {count}); } diff --git a/tests/Exports/ZipImportTest.php b/tests/Exports/ZipImportTest.php index 3644e9bdcb7..ad0e6b24137 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -168,6 +168,8 @@ public function test_import_upload_success() $resp->assertRedirect("/import/{$import->id}"); $this->assertFileExists(storage_path($import->path)); $this->assertActivityExists(ActivityType::IMPORT_CREATE); + + ZipTestHelper::deleteZipForImport($import); } public function test_import_show_page() @@ -325,6 +327,8 @@ public function test_run_revalidates_content() $resp = $this->followRedirects($resp); $resp->assertSeeText('The name field is required.'); $resp->assertSeeText('The id must be an integer.'); + + ZipTestHelper::deleteZipForImport($import); } public function test_run_checks_permissions_on_import() @@ -340,6 +344,8 @@ public function test_run_checks_permissions_on_import() $resp = $this->followRedirects($resp); $resp->assertSeeText('You are lacking the required permissions to create books.'); + + ZipTestHelper::deleteZipForImport($import); } public function test_run_requires_parent_for_chapter_and_page_imports() @@ -379,6 +385,8 @@ public function test_run_validates_correct_parent_type() $resp = $this->followRedirects($resp); $resp->assertSee('Parent book required for chapter import.'); + + ZipTestHelper::deleteZipForImport($import); } protected function runImportFromFile(UploadedFile $file): TestResponse diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php index 2196f361c17..d830d8eb6bf 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -20,7 +20,14 @@ public static function importFromData(array $importData, array $zipData, array $ $import = Import::factory()->create($importData); $zip = static::zipUploadFromData($zipData, $files); - rename($zip->getRealPath(), storage_path($import->path)); + $targetPath = storage_path($import->path); + $targetDir = dirname($targetPath); + + if (!file_exists($targetDir)) { + mkdir($targetDir); + } + + rename($zip->getRealPath(), $targetPath); return $import; } From 0a182a45ba944c807c7a5ba6d6ba3a48809e1dc2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 Nov 2024 15:59:39 +0000 Subject: [PATCH 50/72] ZIP Exports: Added detection/handling of images with external storage Added test to cover. --- app/Exports/ZipExports/ZipReferenceParser.php | 23 +++++++++++++-- .../ModelResolvers/ImageModelResolver.php | 29 +++++++++++++++++-- app/Uploads/ImageStorage.php | 16 ++++++++-- tests/Exports/ZipExportTest.php | 24 +++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/app/Exports/ZipExports/ZipReferenceParser.php b/app/Exports/ZipExports/ZipReferenceParser.php index 5929383b4dd..a6560e3f289 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -11,6 +11,7 @@ use BookStack\References\ModelResolvers\ImageModelResolver; use BookStack\References\ModelResolvers\PageLinkModelResolver; use BookStack\References\ModelResolvers\PagePermalinkModelResolver; +use BookStack\Uploads\ImageStorage; class ZipReferenceParser { @@ -33,8 +34,7 @@ public function __construct( */ public function parseLinks(string $content, callable $handler): string { - $escapedBase = preg_quote(url('/'), '/'); - $linkRegex = "/({$escapedBase}.*?)[\\t\\n\\f>\"'=?#()]/"; + $linkRegex = $this->getLinkRegex(); $matches = []; preg_match_all($linkRegex, $content, $matches); @@ -118,4 +118,23 @@ protected function getModelResolvers(): array return $this->modelResolvers; } + + /** + * Build the regex to identify links we should handle in content. + */ + protected function getLinkRegex(): string + { + $urls = [rtrim(url('/'), '/')]; + $imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/'); + if ($urls[0] !== $imageUrl) { + $urls[] = $imageUrl; + } + + + $urlBaseRegex = implode('|', array_map(function ($url) { + return preg_quote($url, '/'); + }, $urls)); + + return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/"; + } } diff --git a/app/References/ModelResolvers/ImageModelResolver.php b/app/References/ModelResolvers/ImageModelResolver.php index 331dd593b73..2c6c9fecd74 100644 --- a/app/References/ModelResolvers/ImageModelResolver.php +++ b/app/References/ModelResolvers/ImageModelResolver.php @@ -3,19 +3,22 @@ namespace BookStack\References\ModelResolvers; use BookStack\Uploads\Image; +use BookStack\Uploads\ImageStorage; class ImageModelResolver implements CrossLinkModelResolver { + protected ?string $pattern = null; + public function resolve(string $link): ?Image { - $pattern = '/^' . preg_quote(url('/uploads/images'), '/') . '\/(.+)/'; + $pattern = $this->getUrlPattern(); $matches = []; $match = preg_match($pattern, $link, $matches); if (!$match) { return null; } - $path = $matches[1]; + $path = $matches[2]; // Strip thumbnail element from path if existing $originalPathSplit = array_filter(explode('/', $path), function (string $part) { @@ -30,4 +33,26 @@ public function resolve(string $link): ?Image return Image::query()->where('path', '=', $fullPath)->first(); } + + /** + * Get the regex pattern to identify image URLs. + * Caches the pattern since it requires looking up to settings/config. + */ + protected function getUrlPattern(): string + { + if ($this->pattern) { + return $this->pattern; + } + + $urls = [url('/uploads/images')]; + $baseImageUrl = ImageStorage::getPublicUrl('/uploads/images'); + if ($baseImageUrl !== $urls[0]) { + $urls[] = $baseImageUrl; + } + + $imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls)); + $this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/'; + + return $this->pattern; + } } diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php index dc4abc0f281..ddaa26a9400 100644 --- a/app/Uploads/ImageStorage.php +++ b/app/Uploads/ImageStorage.php @@ -110,10 +110,20 @@ public function urlToPath(string $url): ?string } /** - * Gets a public facing url for an image by checking relevant environment variables. + * Gets a public facing url for an image or location at the given path. + */ + public static function getPublicUrl(string $filePath): string + { + return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/'); + } + + /** + * Get the public base URL used for images. + * Will not include any path element of the image file, just the base part + * from where the path is then expected to start from. * If s3-style store is in use it will default to guessing a public bucket URL. */ - public function getPublicUrl(string $filePath): string + protected static function getPublicBaseUrl(): string { $storageUrl = config('filesystems.url'); @@ -131,6 +141,6 @@ public function getPublicUrl(string $filePath): string $basePath = $storageUrl ?: url('/'); - return rtrim($basePath, '/') . $filePath; + return rtrim($basePath, '/'); } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 6e8462f596f..17891c73d73 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -300,6 +300,30 @@ public function test_book_and_chapter_description_links_to_images_in_pages_are_c $this->assertStringContainsString('href="[[bsexport:image:' . $image->id . ']]"', $chapterData['description_html']); } + public function test_image_links_are_handled_when_using_external_storage_url() + { + $page = $this->entities->page(); + + $this->asEditor(); + $this->files->uploadGalleryImageToPage($this, $page); + /** @var Image $image */ + $image = Image::query()->where('type', '=', 'gallery') + ->where('uploaded_to', '=', $page->id)->first(); + + config()->set('filesystems.url', 'https://i.example.com/content'); + + $storageUrl = 'https://i.example.com/content/' . ltrim($image->path, '/'); + $page->html = '

    Original URLStorage URL

    '; + $page->save(); + + $zipResp = $this->get($page->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $pageData = $zip->data['page']; + + $ref = '[[bsexport:image:' . $image->id . ']]'; + $this->assertStringContainsString("Original URLStorage URL", $pageData['html']); + } + public function test_cross_reference_links_external_to_export_are_not_converted() { $page = $this->entities->page(); From edb684c72ce0b1f1cf9be90d338ee08e24b4a0cc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 26 Nov 2024 17:53:20 +0000 Subject: [PATCH 51/72] ZIP Exports: Updated format doc with advisories regarding html/md --- dev/docs/portable-zip-file-format.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index 7e5df3f015b..fbb31785824 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -13,7 +13,8 @@ Following the goals & ideals of BookStack, stability is very important. We aim f - Where reasonably possible, we will attempt to avoid modifications/removals of existing features/properties. - Where potentially breaking changes do have to be made, these will be noted in BookStack release/update notes. -The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage. For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you. +The addition of new features/properties alone are not considered as a breaking change to the format. Breaking changes are considered as such where they could impact common/expected use of the existing properties and features we document, they are not considered based upon user assumptions or any possible breakage. +For example if your application, using the format, breaks because we added a new property while you hard-coded your application to use the third property (instead of a property name), then that's on you. ## Format Outline @@ -57,6 +58,23 @@ Here's an example of each type of such reference that could be used: [[bsexport:book:8]] ``` +## HTML & Markdown Content + +BookStack commonly stores & utilises content in the HTML format. +Properties that expect or provided HTML will either be named `html` or contain `html` in the property name. +While BookStack supports a range of HTML, not all HTML content will be supported by BookStack and be assured to work as desired across all BookStack features. +The HTML supported by BookStack is not yet formally documented, but you can inspect to what the WYSIWYG editor produces as a basis. +Generally, top-level elements should keep to common block formats (p, blockquote, h1, h2 etc...) with no nesting or custom structure apart from common inline elements. +Some areas of BookStack where HTML is used, like book & chapter descriptions, will strictly limit/filter HTML tag & attributes to an allow-list. + +For markdown content, in BookStack we target [the commonmark spec](https://commonmark.org/) with the addition of tables & task-lists. +HTML within markdown is supported but not all HTML is assured to work as advised above. + +### Content Security + +If you're consuming HTML or markdown within an export please consider that the content is not assured to be safe, even if provided directly by a BookStack instance. It's best to treat such content as potentially unsafe. +By default, BookStack performs some basic filtering to remove scripts among other potentially dangerous elements but this is not foolproof. BookStack itself relies on additional security mechanisms such as [CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) to help prevent a range of exploits. + ## Export Data - `data.json` The `data.json` file is a JSON format file which contains all structured data for the export. The properties are as follows: @@ -114,9 +132,9 @@ The `pages` are not all pages within the book, just those that are direct childr - `images` - [Image](#image) array, optional, images used in this page. - `tags` - [Tag](#tag) array, optional, tags assigned to this page. -To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. +To define the page content, either `markdown` or `html` should be provided. Ideally these should be limited to the range of markdown and HTML which BookStack supports. See the ["HTML & Markdown Content"](#html--markdown-content) section. -The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor and display content. +The page editor type, and edit content will be determined by what content is provided. If non-empty `markdown` is provided, the page will be assumed as a markdown editor page (where permissions allow) and the HTML will be rendered from the markdown content. Otherwise, the provided `html` will be used as editor & display content. #### Image From bdca9fc1ce6f3f792106e86348cfb1479f4dd27c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 27 Nov 2024 16:30:19 +0000 Subject: [PATCH 52/72] ZIP Exports: Changed the instance id mechanism Adds an instance id via app settings. --- app/Exports/ZipExports/ZipExportBuilder.php | 4 +-- ...4_11_27_171039_add_instance_id_setting.php | 30 +++++++++++++++++++ dev/docs/portable-zip-file-format.md | 6 ++-- tests/Exports/ZipExportTest.php | 6 ++-- 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2024_11_27_171039_add_instance_id_setting.php diff --git a/app/Exports/ZipExports/ZipExportBuilder.php b/app/Exports/ZipExports/ZipExportBuilder.php index 42fb03541c0..4c5c638f591 100644 --- a/app/Exports/ZipExports/ZipExportBuilder.php +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -69,8 +69,8 @@ protected function build(): string $this->data['exported_at'] = date(DATE_ATOM); $this->data['instance'] = [ - 'version' => trim(file_get_contents(base_path('version'))), - 'id_ciphertext' => encrypt('bookstack'), + 'id' => setting('instance-id', ''), + 'version' => trim(file_get_contents(base_path('version'))), ]; $zipFile = tempnam(sys_get_temp_dir(), 'bszip-'); diff --git a/database/migrations/2024_11_27_171039_add_instance_id_setting.php b/database/migrations/2024_11_27_171039_add_instance_id_setting.php new file mode 100644 index 00000000000..ee1e90d0303 --- /dev/null +++ b/database/migrations/2024_11_27_171039_add_instance_id_setting.php @@ -0,0 +1,30 @@ +insert([ + 'setting_key' => 'instance-id', + 'value' => Str::uuid(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + 'type' => 'string', + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::table('settings')->where('setting_key', '=', 'instance-id')->delete(); + } +}; diff --git a/dev/docs/portable-zip-file-format.md b/dev/docs/portable-zip-file-format.md index fbb31785824..754cb4d3e94 100644 --- a/dev/docs/portable-zip-file-format.md +++ b/dev/docs/portable-zip-file-format.md @@ -93,12 +93,10 @@ The below details the objects & their properties used in Application Data. #### Instance -These details are mainly informational regarding the exporting BookStack instance from where an export was created from. +These details are informational regarding the exporting BookStack instance from where an export was created from. +- `id` - String, required, unique identifier for the BookStack instance. - `version` - String, required, BookStack version of the export source instance. -- `id_ciphertext` - String, required, identifier for the BookStack instance. - -The `id_ciphertext` is the ciphertext of encrypting the text `bookstack`. This is used as a simple & rough way for a BookStack instance to be able to identify if they were the source (by attempting to decrypt the ciphertext). #### Book diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 17891c73d73..ebe07d052bc 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -54,8 +54,10 @@ public function test_export_metadata() $version = trim(file_get_contents(base_path('version'))); $this->assertEquals($version, $zip->data['instance']['version']); - $instanceId = decrypt($zip->data['instance']['id_ciphertext']); - $this->assertEquals('bookstack', $instanceId); + $zipInstanceId = $zip->data['instance']['id']; + $instanceId = setting('instance-id'); + $this->assertNotEmpty($instanceId); + $this->assertEquals($instanceId, $zipInstanceId); } public function test_page_export() From 227c5e155b60ba9a442d23c64a9846366c3e15ac Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 1 Dec 2024 16:02:21 +0000 Subject: [PATCH 53/72] Dev Docker: Fixed missing gd jpeg handling, forced migrations Migrations run without force could fail startup in certain environment conditions (when testing production env). Also updated paths permission handling to update more needed locations. --- dev/docker/Dockerfile | 7 +++++-- dev/docker/entrypoint.app.sh | 4 ++-- dev/docs/development.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 9f283c75544..edab90ca1c5 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -6,15 +6,18 @@ RUN apt-get update && \ git \ zip \ unzip \ - libpng-dev \ + libfreetype-dev \ + libjpeg62-turbo-dev \ libldap2-dev \ + libpng-dev \ libzip-dev \ wait-for-it && \ rm -rf /var/lib/apt/lists/* # Install PHP extensions RUN docker-php-ext-configure ldap --with-libdir="lib/$(gcc -dumpmachine)" && \ - docker-php-ext-install pdo_mysql gd ldap zip && \ + docker-php-ext-configure gd --with-freetype --with-jpeg && \ + docker-php-ext-install -j$(nproc) pdo_mysql gd ldap zip && \ pecl install xdebug && \ docker-php-ext-enable xdebug diff --git a/dev/docker/entrypoint.app.sh b/dev/docker/entrypoint.app.sh index e91d34a7133..b09edda8863 100755 --- a/dev/docker/entrypoint.app.sh +++ b/dev/docker/entrypoint.app.sh @@ -9,7 +9,7 @@ if [[ -n "$1" ]]; then else composer install wait-for-it db:3306 -t 45 - php artisan migrate --database=mysql - chown -R www-data:www-data storage + php artisan migrate --database=mysql --force + chown -R www-data storage public/uploads bootstrap/cache exec apache2-foreground fi diff --git a/dev/docs/development.md b/dev/docs/development.md index 3c7a6e9d299..0324140f8bd 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -82,7 +82,7 @@ If all the conditions are met, you can proceed with the following steps: 1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`. 2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host. -3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory. +3. **Run `chgrp -R docker storage`**. The development container will chown the `storage`, `public/uploads` and `bootstrap/cache` directories to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory. 4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done. 5. You can now login with `admin@admin.com` and `password` as password on `localhost:8080` (or another port if specified). From 90341e0e00a832e78df8a394487a073a475003ed Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 1 Dec 2024 18:42:54 +0000 Subject: [PATCH 54/72] LDAP: Review and testing of mulitple-display-name attr support Review of #5295 Added test to cover functionality. Moved splitting from config to service. --- app/Access/LdapService.php | 30 ++++++++++++++---------------- app/Config/services.php | 2 +- tests/Auth/LdapTest.php | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/app/Access/LdapService.php b/app/Access/LdapService.php index ef6d33f4db1..e5037ad2f95 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -72,25 +72,23 @@ private function getUserWithAttributes(string $userName, array $attributes): ?ar } /** - * Calculate the display name. + * Build the user display name from the (potentially multiple) attributes defined by the configuration. */ - protected function getUserDisplayName(array $displayNameAttr, array $userDetails, string $defaultValue): string + protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string { - $displayName = []; - foreach ($displayNameAttr as $dnAttr) { + $displayNameParts = []; + foreach ($displayNameAttrs as $dnAttr) { $dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null); - if ($dnComponent !== null) { - $displayName[] = $dnComponent; + if ($dnComponent) { + $displayNameParts[] = $dnComponent; } } - if (count($displayName) == 0) { - $displayName = $defaultValue; - } else { - $displayName = implode(' ', $displayName); + if (empty($displayNameParts)) { + return $defaultValue; } - return $displayName; + return implode(' ', $displayNameParts); } /** @@ -103,12 +101,12 @@ public function getUserDetails(string $userName): ?array { $idAttr = $this->config['id_attribute']; $emailAttr = $this->config['email_attribute']; - $displayNameAttr = $this->config['display_name_attribute']; + $displayNameAttrs = explode('|', $this->config['display_name_attribute']); $thumbnailAttr = $this->config['thumbnail_attribute']; - $user = $this->getUserWithAttributes($userName, array_filter(array_merge($displayNameAttr, [ - 'cn', 'dn', $idAttr, $emailAttr, $thumbnailAttr, - ]))); + $user = $this->getUserWithAttributes($userName, array_filter([ + 'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr, + ])); if (is_null($user)) { return null; @@ -117,7 +115,7 @@ public function getUserDetails(string $userName): ?array $userCn = $this->getUserResponseProperty($user, 'cn', null); $formatted = [ 'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']), - 'name' => $this->getUserDisplayName($displayNameAttr, $user, $userCn), + 'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn), 'dn' => $user['dn'], 'email' => $this->getUserResponseProperty($user, $emailAttr, null), 'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null, diff --git a/app/Config/services.php b/app/Config/services.php index 4e27896870a..d7345823150 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -127,7 +127,7 @@ 'version' => env('LDAP_VERSION', false), 'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), - 'display_name_attribute' => explode('|', env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn')), + 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS', false), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 27169a2becf..9a00c983a50 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -29,7 +29,7 @@ protected function setUp(): void 'auth.defaults.guard' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'services.ldap.email_attribute' => 'mail', - 'services.ldap.display_name_attribute' => ['cn'], + 'services.ldap.display_name_attribute' => 'cn', 'services.ldap.id_attribute' => 'uid', 'services.ldap.user_to_groups' => false, 'services.ldap.version' => '3', @@ -581,7 +581,7 @@ public function test_login_group_mapping_does_not_conflict_with_default_role() public function test_login_uses_specified_display_name_attribute() { app('config')->set([ - 'services.ldap.display_name_attribute' => ['displayName'], + 'services.ldap.display_name_attribute' => 'displayName', ]); $this->commonLdapMocks(1, 1, 2, 4, 2); @@ -603,10 +603,37 @@ public function test_login_uses_specified_display_name_attribute() $this->assertDatabaseHas('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']); } + public function test_login_uses_multiple_display_properties_if_defined() + { + app('config')->set([ + 'services.ldap.display_name_attribute' => 'firstname|middlename|noname|lastname', + ]); + + $this->commonLdapMocks(1, 1, 1, 2, 1); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => 'dc=test' . config('services.ldap.base_dn'), + 'firstname' => ['Barry'], + 'middlename' => ['Elliott'], + 'lastname' => ['Chuckle'], + 'mail' => [$this->mockUser->email], + ]]); + + $this->mockUserLogin(); + + $this->assertDatabaseHas('users', [ + 'email' => $this->mockUser->email, + 'name' => 'Barry Elliott Chuckle', + ]); + } + public function test_login_uses_default_display_name_attribute_if_specified_not_present() { app('config')->set([ - 'services.ldap.display_name_attribute' => ['displayName'], + 'services.ldap.display_name_attribute' => 'displayName', ]); $this->commonLdapMocks(1, 1, 2, 4, 2); From 0f9957bc036e40d068dfb0958560485f7456964a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 2 Dec 2024 11:46:56 +0000 Subject: [PATCH 55/72] MD Exports: Added HTML description conversion Also updated tests to cover checking description use/conversion. Made during review of #5313 --- app/Entities/Tools/ExportFormatter.php | 12 +++++++----- tests/Entity/ExportTest.php | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/Entities/Tools/ExportFormatter.php b/app/Entities/Tools/ExportFormatter.php index e85992a9d70..0af68b8db3c 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -316,8 +316,9 @@ public function chapterToMarkdown(Chapter $chapter): string { $text = '# ' . $chapter->name . "\n\n"; - if (!empty($chapter->description)) { - $text .= $chapter->description . "\n\n"; + $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert(); + if ($description) { + $text .= $description . "\n\n"; } foreach ($chapter->pages as $page) { @@ -334,9 +335,10 @@ public function bookToMarkdown(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; - - if (!empty($book->description)) { - $text .= $book->description . "\n\n"; + + $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert(); + if ($description) { + $text .= $description . "\n\n"; } foreach ($bookTree as $bookChild) { diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php index 7aafa3b7927..97b1ff1bcfd 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -417,23 +417,35 @@ public function test_page_markdown_export_converts_html_where_no_markdown() public function test_chapter_markdown_export() { $chapter = $this->entities->chapter(); + $chapter->description_html = '

    My chapter description

    '; + $chapter->save(); $page = $chapter->pages()->first(); + $resp = $this->asEditor()->get($chapter->getUrl('/export/markdown')); $resp->assertSee('# ' . $chapter->name); $resp->assertSee('# ' . $page->name); + $resp->assertSee('My **chapter** description'); } public function test_book_markdown_export() { $book = Book::query()->whereHas('pages')->whereHas('chapters')->first(); + $book->description_html = '

    My book description

    '; + $book->save(); + $chapter = $book->chapters()->first(); + $chapter->description_html = '

    My chapter description

    '; + $chapter->save(); + $page = $chapter->pages()->first(); $resp = $this->asEditor()->get($book->getUrl('/export/markdown')); $resp->assertSee('# ' . $book->name); $resp->assertSee('# ' . $chapter->name); $resp->assertSee('# ' . $page->name); + $resp->assertSee('My **book** description'); + $resp->assertSee('My **chapter** description'); } public function test_book_markdown_export_concats_immediate_pages_with_newlines() From fec44452cb67819b594bdfca4ca37e4a20c0f42e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 13:47:45 +0000 Subject: [PATCH 56/72] Search API: Updated handling of parent detail, added testing Review of #5280. - Removed additional non-needed loads which could ignore permissions. - Updated new formatter method name to be more specific on use. - Added test case to cover changes. - Updated API examples to align parent id/info in info to be representative. --- app/Api/ApiEntityListFormatter.php | 32 ++---- app/Search/SearchApiController.php | 15 +-- dev/api/responses/search-all.json | 172 ++++++++++++++--------------- tests/Api/SearchApiTest.php | 44 +++++++- 4 files changed, 142 insertions(+), 121 deletions(-) diff --git a/app/Api/ApiEntityListFormatter.php b/app/Api/ApiEntityListFormatter.php index 7c2d09d4f3a..3c94d96ee60 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -2,6 +2,7 @@ namespace BookStack\Api; +use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; @@ -72,20 +73,20 @@ public function withTags(): self } /** - * Enable the inclusion of related book and chapter titles in the response. + * Include parent book/chapter info in the formatted data. */ - public function withRelatedData(): self + public function withParents(): self { $this->withField('book', function (Entity $entity) { - if (method_exists($entity, 'book')) { - return $entity->book()->select(['id', 'name', 'slug'])->first(); + if ($entity instanceof BookChild && $entity->book) { + return $entity->book->only(['id', 'name', 'slug']); } return null; }); $this->withField('chapter', function (Entity $entity) { - if ($entity instanceof Page && $entity->chapter_id) { - return $entity->chapter()->select(['id', 'name', 'slug'])->first(); + if ($entity instanceof Page && $entity->chapter) { + return $entity->chapter->only(['id', 'name', 'slug']); } return null; }); @@ -99,8 +100,6 @@ public function withRelatedData(): self */ public function format(): array { - $this->loadRelatedData(); - $results = []; foreach ($this->list as $item) { @@ -110,23 +109,6 @@ public function format(): array return $results; } - /** - * Eager load the related book and chapter data when needed. - */ - protected function loadRelatedData(): void - { - $pages = collect($this->list)->filter(fn($item) => $item instanceof Page); - - foreach ($this->list as $entity) { - if (method_exists($entity, 'book')) { - $entity->load('book'); - } - if ($entity instanceof Page && $entity->chapter_id) { - $entity->load('chapter'); - } - } - } - /** * Format a single entity item to a plain array. */ diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 28a3b53e6c3..79cd8cfabd0 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,21 +9,18 @@ class SearchApiController extends ApiController { - protected SearchRunner $searchRunner; - protected SearchResultsFormatter $resultsFormatter; - protected $rules = [ 'all' => [ 'query' => ['required'], - 'page' => ['integer', 'min:1'], + 'page' => ['integer', 'min:1'], 'count' => ['integer', 'min:1', 'max:100'], ], ]; - public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter) - { - $this->searchRunner = $searchRunner; - $this->resultsFormatter = $resultsFormatter; + public function __construct( + protected SearchRunner $searchRunner, + protected SearchResultsFormatter $resultsFormatter + ) { } /** @@ -50,7 +47,7 @@ public function all(Request $request) $this->resultsFormatter->format($results['results']->all(), $options); $data = (new ApiEntityListFormatter($results['results']->all())) - ->withType()->withTags()->withRelatedData() + ->withType()->withTags()->withParents() ->withField('preview_html', function (Entity $entity) { return [ 'name' => (string) $entity->getAttribute('preview_name'), diff --git a/dev/api/responses/search-all.json b/dev/api/responses/search-all.json index f60a12f750d..2ad89641693 100644 --- a/dev/api/responses/search-all.json +++ b/dev/api/responses/search-all.json @@ -1,92 +1,92 @@ { - "data": [ + "data": [ + { + "id": 84, + "book_id": 1, + "slug": "a-chapter-for-cats", + "name": "A chapter for cats", + "created_at": "2021-11-14T15:57:35.000000Z", + "updated_at": "2021-11-14T15:57:35.000000Z", + "type": "chapter", + "url": "https://example.com/books/cats/chapter/a-chapter-for-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "preview_html": { + "name": "A chapter for cats", + "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" + }, + "tags": [] + }, + { + "name": "The hows and whys of cats", + "id": 396, + "slug": "the-hows-and-whys-of-cats", + "book_id": 1, + "chapter_id": 75, + "draft": false, + "template": false, + "created_at": "2021-05-15T16:28:10.000000Z", + "updated_at": "2021-11-14T15:56:49.000000Z", + "type": "page", + "url": "https://example.com/books/cats/page/the-hows-and-whys-of-cats", + "book": { + "id": 1, + "name": "Cats", + "slug": "cats" + }, + "chapter": { + "id": 75, + "name": "A chapter for cats", + "slug": "a-chapter-for-cats" + }, + "preview_html": { + "name": "The hows and whys of cats", + "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." + }, + "tags": [ { - "id": 84, - "book_id": 1, - "slug": "a-chapter-for-cats", - "name": "A chapter for cats", - "created_at": "2021-11-14T15:57:35.000000Z", - "updated_at": "2021-11-14T15:57:35.000000Z", - "type": "chapter", - "url": "https://example.com/books/my-book/chapter/a-chapter-for-cats", - "book": { - "id": 1, - "name": "Cats", - "slug": "cats" - }, - "preview_html": { - "name": "A chapter for cats", - "content": "...once a bunch of cats named tony...behaviour of cats is unsuitable" - }, - "tags": [] + "name": "Animal", + "value": "Cat", + "order": 0 }, { - "name": "The hows and whys of cats", - "id": 396, - "slug": "the-hows-and-whys-of-cats", - "book_id": 1, - "chapter_id": 75, - "draft": false, - "template": false, - "created_at": "2021-05-15T16:28:10.000000Z", - "updated_at": "2021-11-14T15:56:49.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/the-hows-and-whys-of-cats", - "book": { - "id": 1, - "name": "Cats", - "slug": "cats" - }, - "chapter": { - "id": 84, - "name": "A chapter for cats", - "slug": "a-chapter-for-cats" - }, - "preview_html": { - "name": "The hows and whys of cats", - "content": "...people ask why cats? but there are...the reason that cats are fast are due to..." - }, - "tags": [ - { - "name": "Animal", - "value": "Cat", - "order": 0 - }, - { - "name": "Category", - "value": "Top Content", - "order": 0 - } - ] - }, - { - "name": "How advanced are cats?", - "id": 362, - "slug": "how-advanced-are-cats", - "book_id": 13, - "chapter_id": 73, - "draft": false, - "template": false, - "created_at": "2020-11-29T21:55:07.000000Z", - "updated_at": "2021-11-14T16:02:39.000000Z", - "type": "page", - "url": "https://example.com/books/my-book/page/how-advanced-are-cats", - "book": { - "id": 1, - "name": "Cats", - "slug": "cats" - }, - "chapter": { - "id": 84, - "name": "A chapter for cats", - "slug": "a-chapter-for-cats" - }, - "preview_html": { - "name": "How advanced are cats?", - "content": "cats are some of the most advanced animals in the world." - }, - "tags": [] + "name": "Category", + "value": "Top Content", + "order": 0 } - ], - "total": 3 + ] + }, + { + "name": "How advanced are cats?", + "id": 362, + "slug": "how-advanced-are-cats", + "book_id": 13, + "chapter_id": 73, + "draft": false, + "template": false, + "created_at": "2020-11-29T21:55:07.000000Z", + "updated_at": "2021-11-14T16:02:39.000000Z", + "type": "page", + "url": "https://example.com/books/big-cats/page/how-advanced-are-cats", + "book": { + "id": 13, + "name": "Big Cats", + "slug": "big-cats" + }, + "chapter": { + "id": 73, + "name": "A chapter for bigger cats", + "slug": "a-chapter-for-bigger-cats" + }, + "preview_html": { + "name": "How advanced are cats?", + "content": "cats are some of the most advanced animals in the world." + }, + "tags": [] + } + ], + "total": 3 } diff --git a/tests/Api/SearchApiTest.php b/tests/Api/SearchApiTest.php index 3f2eb395c11..9da7900ca9a 100644 --- a/tests/Api/SearchApiTest.php +++ b/tests/Api/SearchApiTest.php @@ -13,7 +13,7 @@ class SearchApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/search'; + protected string $baseEndpoint = '/api/search'; public function test_all_endpoint_returns_search_filtered_results_with_query() { @@ -74,4 +74,46 @@ public function test_all_endpoint_requires_query_parameter() $resp = $this->actingAsApiEditor()->get($this->baseEndpoint . '?query=myqueryvalue'); $resp->assertOk(); } + + public function test_all_endpoint_includes_parent_details_where_visible() + { + $page = $this->entities->pageWithinChapter(); + $chapter = $page->chapter; + $book = $page->book; + + $page->update(['name' => 'name with superextrauniquevalue within']); + $page->indexForSearch(); + + $editor = $this->users->editor(); + $this->actingAsApiEditor(); + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonFragment([ + 'id' => $page->id, + 'type' => 'page', + 'book' => [ + 'id' => $book->id, + 'name' => $book->name, + 'slug' => $book->slug, + ], + 'chapter' => [ + 'id' => $chapter->id, + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], + ]); + + $this->permissions->disableEntityInheritedPermissions($chapter); + $this->permissions->setEntityPermissions($page, ['view'], [$editor->roles()->first()]); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonPath('data.0.book.name', $book->name); + $resp->assertJsonMissingPath('data.0.chapter'); + + $this->permissions->disableEntityInheritedPermissions($book); + + $resp = $this->getJson($this->baseEndpoint . '?query=superextrauniquevalue'); + $resp->assertJsonPath('data.0.id', $page->id); + $resp->assertJsonMissingPath('data.0.book.name'); + } } From f3fa63a5ae5d671d10c9313965723683608ddc4e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 16:24:49 +0000 Subject: [PATCH 57/72] Lexical: Merged custom paragraph node, removed old format/indent refs Start of work to merge custom nodes into lexical, removing old unused format/indent core logic while extending common block elements where possible. --- .../js/wysiwyg/lexical/core/LexicalEvents.ts | 3 - .../wysiwyg/lexical/core/LexicalMutations.ts | 9 -- .../wysiwyg/lexical/core/LexicalReconciler.ts | 67 ---------- .../lexical/core/__tests__/utils/index.ts | 8 -- .../lexical/core/nodes/CommonBlockNode.ts | 54 ++++++++ .../lexical/core/nodes/LexicalElementNode.ts | 41 ------ .../core/nodes/LexicalParagraphNode.ts | 69 +++------- .../lexical/core/nodes/LexicalRootNode.ts | 4 - resources/js/wysiwyg/lexical/html/index.ts | 4 - resources/js/wysiwyg/lexical/link/index.ts | 4 - .../lexical/list/LexicalListItemNode.ts | 38 ------ .../js/wysiwyg/lexical/list/formatList.ts | 6 - .../js/wysiwyg/lexical/rich-text/index.ts | 56 +------- .../lexical/selection/range-selection.ts | 8 -- .../js/wysiwyg/nodes/custom-paragraph.ts | 123 ------------------ resources/js/wysiwyg/nodes/index.ts | 9 +- .../wysiwyg/services/drop-paste-handling.ts | 4 +- .../js/wysiwyg/services/keyboard-handling.ts | 4 +- resources/js/wysiwyg/todo.md | 6 +- resources/js/wysiwyg/utils/formats.ts | 13 +- resources/js/wysiwyg/utils/nodes.ts | 4 +- resources/js/wysiwyg/utils/selection.ts | 6 +- 22 files changed, 95 insertions(+), 445 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-paragraph.ts diff --git a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts index 5fd671a76c5..c70a906a08e 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEvents.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEvents.ts @@ -355,7 +355,6 @@ function onSelectionChange( lastNode instanceof ParagraphNode && lastNode.getChildrenSize() === 0 ) { - selection.format = lastNode.getTextFormat(); selection.style = lastNode.getTextStyle(); } else { selection.format = 0; @@ -578,7 +577,6 @@ function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { if ($isRangeSelection(selection)) { const anchorNode = selection.anchor.getNode(); anchorNode.markDirty(); - selection.format = anchorNode.getFormat(); invariant( $isTextNode(anchorNode), 'Anchor node must be a TextNode', @@ -912,7 +910,6 @@ function onCompositionStart( // need to invoke the empty space heuristic below. anchor.type === 'element' || !selection.isCollapsed() || - node.getFormat() !== selection.format || ($isTextNode(node) && node.getStyle() !== selection.style) ) { // We insert a zero width character, ready for the composition diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts index 56f364501ee..c24dc9ebb3c 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts @@ -96,15 +96,6 @@ function shouldUpdateTextNodeFromMutation( targetDOM: Node, targetNode: TextNode, ): boolean { - if ($isRangeSelection(selection)) { - const anchorNode = selection.anchor.getNode(); - if ( - anchorNode.is(targetNode) && - selection.format !== anchorNode.getFormat() - ) { - return false; - } - } return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); } diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index 09d01bffd43..7843027d713 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -17,7 +17,6 @@ import type {NodeKey, NodeMap} from './LexicalNode'; import type {ElementNode} from './nodes/LexicalElementNode'; import invariant from 'lexical/shared/invariant'; -import normalizeClassNames from 'lexical/shared/normalizeClassNames'; import { $isDecoratorNode, @@ -117,51 +116,6 @@ function setTextAlign(domStyle: CSSStyleDeclaration, value: string): void { domStyle.setProperty('text-align', value); } -const DEFAULT_INDENT_VALUE = '40px'; - -function setElementIndent(dom: HTMLElement, indent: number): void { - const indentClassName = activeEditorConfig.theme.indent; - - if (typeof indentClassName === 'string') { - const elementHasClassName = dom.classList.contains(indentClassName); - - if (indent > 0 && !elementHasClassName) { - dom.classList.add(indentClassName); - } else if (indent < 1 && elementHasClassName) { - dom.classList.remove(indentClassName); - } - } - - const indentationBaseValue = - getComputedStyle(dom).getPropertyValue('--lexical-indent-base-value') || - DEFAULT_INDENT_VALUE; - - dom.style.setProperty( - 'padding-inline-start', - indent === 0 ? '' : `calc(${indent} * ${indentationBaseValue})`, - ); -} - -function setElementFormat(dom: HTMLElement, format: number): void { - const domStyle = dom.style; - - if (format === 0) { - setTextAlign(domStyle, ''); - } else if (format === IS_ALIGN_LEFT) { - setTextAlign(domStyle, 'left'); - } else if (format === IS_ALIGN_CENTER) { - setTextAlign(domStyle, 'center'); - } else if (format === IS_ALIGN_RIGHT) { - setTextAlign(domStyle, 'right'); - } else if (format === IS_ALIGN_JUSTIFY) { - setTextAlign(domStyle, 'justify'); - } else if (format === IS_ALIGN_START) { - setTextAlign(domStyle, 'start'); - } else if (format === IS_ALIGN_END) { - setTextAlign(domStyle, 'end'); - } -} - function $createNode( key: NodeKey, parentDOM: null | HTMLElement, @@ -185,22 +139,14 @@ function $createNode( } if ($isElementNode(node)) { - const indent = node.__indent; const childrenSize = node.__size; - if (indent !== 0) { - setElementIndent(dom, indent); - } if (childrenSize !== 0) { const endIndex = childrenSize - 1; const children = createChildrenArray(node, activeNextNodeMap); $createChildren(children, node, 0, endIndex, dom, null); } - const format = node.__format; - if (format !== 0) { - setElementFormat(dom, format); - } if (!node.isInline()) { reconcileElementTerminatingLineBreak(null, node, dom); } @@ -349,10 +295,8 @@ function reconcileParagraphFormat(element: ElementNode): void { if ( $isParagraphNode(element) && subTreeTextFormat != null && - subTreeTextFormat !== element.__textFormat && !activeEditorStateReadOnly ) { - element.setTextFormat(subTreeTextFormat); element.setTextStyle(subTreeTextStyle); } } @@ -563,17 +507,6 @@ function $reconcileNode( if ($isElementNode(prevNode) && $isElementNode(nextNode)) { // Reconcile element children - const nextIndent = nextNode.__indent; - - if (nextIndent !== prevNode.__indent) { - setElementIndent(dom, nextIndent); - } - - const nextFormat = nextNode.__format; - - if (nextFormat !== prevNode.__format) { - setElementFormat(dom, nextFormat); - } if (isDirty) { $reconcileChildrenWithDirection(prevNode, nextNode, dom); if (!$isRootNode(nextNode) && !nextNode.isInline()) { diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index f7230595a43..a4d74210e41 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -129,8 +129,6 @@ export class TestElementNode extends ElementNode { serializedNode: SerializedTestElementNode, ): TestInlineElementNode { const node = $createTestInlineElementNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -195,8 +193,6 @@ export class TestInlineElementNode extends ElementNode { serializedNode: SerializedTestInlineElementNode, ): TestInlineElementNode { const node = $createTestInlineElementNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -241,8 +237,6 @@ export class TestShadowRootNode extends ElementNode { serializedNode: SerializedTestShadowRootNode, ): TestShadowRootNode { const node = $createTestShadowRootNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -322,8 +316,6 @@ export class TestExcludeFromCopyElementNode extends ElementNode { serializedNode: SerializedTestExcludeFromCopyElementNode, ): TestExcludeFromCopyElementNode { const node = $createTestExcludeFromCopyElementNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } diff --git a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts new file mode 100644 index 00000000000..37ca1cdef7f --- /dev/null +++ b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts @@ -0,0 +1,54 @@ +import {ElementNode} from "./LexicalElementNode"; +import {CommonBlockAlignment, SerializedCommonBlockNode} from "../../../nodes/_common"; + + +export class CommonBlockNode extends ElementNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; + __inset: number = 0; + + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + setInset(size: number) { + const self = this.getWritable(); + self.__inset = size; + } + + getInset(): number { + const self = this.getLatest(); + return self.__inset; + } + + exportJSON(): SerializedCommonBlockNode { + return { + ...super.exportJSON(), + id: this.__id, + alignment: this.__alignment, + inset: this.__inset, + }; + } +} + +export function copyCommonBlockProperties(from: CommonBlockNode, to: CommonBlockNode): void { + to.__id = from.__id; + to.__alignment = from.__alignment; + to.__inset = from.__inset; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 88c6d56780f..002d825d6ea 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -42,8 +42,6 @@ export type SerializedElementNode< { children: Array; direction: 'ltr' | 'rtl' | null; - format: ElementFormatType; - indent: number; }, SerializedLexicalNode >; @@ -74,12 +72,8 @@ export class ElementNode extends LexicalNode { /** @internal */ __size: number; /** @internal */ - __format: number; - /** @internal */ __style: string; /** @internal */ - __indent: number; - /** @internal */ __dir: 'ltr' | 'rtl' | null; constructor(key?: NodeKey) { @@ -87,9 +81,7 @@ export class ElementNode extends LexicalNode { this.__first = null; this.__last = null; this.__size = 0; - this.__format = 0; this.__style = ''; - this.__indent = 0; this.__dir = null; } @@ -98,28 +90,14 @@ export class ElementNode extends LexicalNode { this.__first = prevNode.__first; this.__last = prevNode.__last; this.__size = prevNode.__size; - this.__indent = prevNode.__indent; - this.__format = prevNode.__format; this.__style = prevNode.__style; this.__dir = prevNode.__dir; } - getFormat(): number { - const self = this.getLatest(); - return self.__format; - } - getFormatType(): ElementFormatType { - const format = this.getFormat(); - return ELEMENT_FORMAT_TO_TYPE[format] || ''; - } getStyle(): string { const self = this.getLatest(); return self.__style; } - getIndent(): number { - const self = this.getLatest(); - return self.__indent; - } getChildren(): Array { const children: Array = []; let child: T | null = this.getFirstChild(); @@ -301,13 +279,6 @@ export class ElementNode extends LexicalNode { const self = this.getLatest(); return self.__dir; } - hasFormat(type: ElementFormatType): boolean { - if (type !== '') { - const formatFlag = ELEMENT_TYPE_TO_FORMAT[type]; - return (this.getFormat() & formatFlag) !== 0; - } - return false; - } // Mutators @@ -378,21 +349,11 @@ export class ElementNode extends LexicalNode { self.__dir = direction; return self; } - setFormat(type: ElementFormatType): this { - const self = this.getWritable(); - self.__format = type !== '' ? ELEMENT_TYPE_TO_FORMAT[type] : 0; - return this; - } setStyle(style: string): this { const self = this.getWritable(); self.__style = style || ''; return this; } - setIndent(indentLevel: number): this { - const self = this.getWritable(); - self.__indent = indentLevel; - return this; - } splice( start: number, deleteCount: number, @@ -528,8 +489,6 @@ export class ElementNode extends LexicalNode { return { children: [], direction: this.getDirection(), - format: this.getFormatType(), - indent: this.getIndent(), type: 'element', version: 1, }; diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts index 4e69dc21c3c..6517d939eda 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -19,39 +19,36 @@ import type { LexicalNode, NodeKey, } from '../LexicalNode'; -import type { - ElementFormatType, - SerializedElementNode, -} from './LexicalElementNode'; import type {RangeSelection} from 'lexical'; -import {TEXT_TYPE_TO_FORMAT} from '../LexicalConstants'; import { $applyNodeReplacement, getCachedClassNameArray, isHTMLElement, } from '../LexicalUtils'; -import {ElementNode} from './LexicalElementNode'; -import {$isTextNode, TextFormatType} from './LexicalTextNode'; +import {$isTextNode} from './LexicalTextNode'; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../../nodes/_common"; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; export type SerializedParagraphNode = Spread< { - textFormat: number; textStyle: string; }, - SerializedElementNode + SerializedCommonBlockNode >; /** @noInheritDoc */ -export class ParagraphNode extends ElementNode { +export class ParagraphNode extends CommonBlockNode { ['constructor']!: KlassConstructor; /** @internal */ - __textFormat: number; __textStyle: string; constructor(key?: NodeKey) { super(key); - this.__textFormat = 0; this.__textStyle = ''; } @@ -59,22 +56,6 @@ export class ParagraphNode extends ElementNode { return 'paragraph'; } - getTextFormat(): number { - const self = this.getLatest(); - return self.__textFormat; - } - - setTextFormat(type: number): this { - const self = this.getWritable(); - self.__textFormat = type; - return self; - } - - hasTextFormat(type: TextFormatType): boolean { - const formatFlag = TEXT_TYPE_TO_FORMAT[type]; - return (this.getTextFormat() & formatFlag) !== 0; - } - getTextStyle(): string { const self = this.getLatest(); return self.__textStyle; @@ -92,8 +73,8 @@ export class ParagraphNode extends ElementNode { afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); - this.__textFormat = prevNode.__textFormat; this.__textStyle = prevNode.__textStyle; + copyCommonBlockProperties(prevNode, this); } // View @@ -105,6 +86,9 @@ export class ParagraphNode extends ElementNode { const domClassList = dom.classList; domClassList.add(...classNames); } + + updateElementWithCommonBlockProps(dom, this); + return dom; } updateDOM( @@ -112,7 +96,7 @@ export class ParagraphNode extends ElementNode { dom: HTMLElement, config: EditorConfig, ): boolean { - return false; + return commonPropertiesDifferent(prevNode, this); } static importDOM(): DOMConversionMap | null { @@ -131,16 +115,6 @@ export class ParagraphNode extends ElementNode { if (this.isEmpty()) { element.append(document.createElement('br')); } - - const formatType = this.getFormatType(); - element.style.textAlign = formatType; - - const indent = this.getIndent(); - if (indent > 0) { - // padding-inline-start is not widely supported in email HTML, but - // Lexical Reconciler uses padding-inline-start. Using text-indent instead. - element.style.textIndent = `${indent * 20}px`; - } } return { @@ -150,16 +124,13 @@ export class ParagraphNode extends ElementNode { static importJSON(serializedNode: SerializedParagraphNode): ParagraphNode { const node = $createParagraphNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); - node.setTextFormat(serializedNode.textFormat); + deserializeCommonBlockNode(serializedNode, node); return node; } exportJSON(): SerializedParagraphNode { return { ...super.exportJSON(), - textFormat: this.getTextFormat(), textStyle: this.getTextStyle(), type: 'paragraph', version: 1, @@ -173,11 +144,9 @@ export class ParagraphNode extends ElementNode { restoreSelection: boolean, ): ParagraphNode { const newElement = $createParagraphNode(); - newElement.setTextFormat(rangeSelection.format); newElement.setTextStyle(rangeSelection.style); const direction = this.getDirection(); newElement.setDirection(direction); - newElement.setFormat(this.getFormatType()); newElement.setStyle(this.getTextStyle()); this.insertAfter(newElement, restoreSelection); return newElement; @@ -210,13 +179,7 @@ export class ParagraphNode extends ElementNode { function $convertParagraphElement(element: HTMLElement): DOMConversionOutput { const node = $createParagraphNode(); - if (element.style) { - node.setFormat(element.style.textAlign as ElementFormatType); - const indent = parseInt(element.style.textIndent, 10) / 20; - if (indent > 0) { - node.setIndent(indent); - } - } + setCommonBlockPropsFromElement(element, node); return {node}; } diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts index 74c8d5a7f9b..a1c8813c390 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalRootNode.ts @@ -99,8 +99,6 @@ export class RootNode extends ElementNode { static importJSON(serializedNode: SerializedRootNode): RootNode { // We don't create a root, and instead use the existing root. const node = $getRoot(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -109,8 +107,6 @@ export class RootNode extends ElementNode { return { children: [], direction: this.getDirection(), - format: this.getFormatType(), - indent: this.getIndent(), type: 'root', version: 1, }; diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 2975315cc35..3e962ec72f7 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -327,9 +327,6 @@ function wrapContinuousInlines( for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ($isBlockElementNode(node)) { - if (textAlign && !node.getFormat()) { - node.setFormat(textAlign); - } out.push(node); } else { continuousInlines.push(node); @@ -338,7 +335,6 @@ function wrapContinuousInlines( (i < nodes.length - 1 && $isBlockElementNode(nodes[i + 1])) ) { const wrapper = createWrapperFn(); - wrapper.setFormat(textAlign); wrapper.append(...continuousInlines); out.push(wrapper); continuousInlines = []; diff --git a/resources/js/wysiwyg/lexical/link/index.ts b/resources/js/wysiwyg/lexical/link/index.ts index fe2b9757048..884fe9153a0 100644 --- a/resources/js/wysiwyg/lexical/link/index.ts +++ b/resources/js/wysiwyg/lexical/link/index.ts @@ -162,8 +162,6 @@ export class LinkNode extends ElementNode { target: serializedNode.target, title: serializedNode.title, }); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } @@ -402,8 +400,6 @@ export class AutoLinkNode extends LinkNode { target: serializedNode.target, title: serializedNode.title, }); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 5026a01293e..c20329e4be9 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -126,14 +126,12 @@ export class ListItemNode extends ElementNode { const node = $createListItemNode(); node.setChecked(serializedNode.checked); node.setValue(serializedNode.value); - node.setFormat(serializedNode.format); node.setDirection(serializedNode.direction); return node; } exportDOM(editor: LexicalEditor): DOMExportOutput { const element = this.createDOM(editor._config); - element.style.textAlign = this.getFormatType(); return { element, }; @@ -172,7 +170,6 @@ export class ListItemNode extends ElementNode { if ($isListItemNode(replaceWithNode)) { return super.replace(replaceWithNode); } - this.setIndent(0); const list = this.getParentOrThrow(); if (!$isListNode(list)) { return replaceWithNode; @@ -351,41 +348,6 @@ export class ListItemNode extends ElementNode { this.setChecked(!this.__checked); } - getIndent(): number { - // If we don't have a parent, we are likely serializing - const parent = this.getParent(); - if (parent === null) { - return this.getLatest().__indent; - } - // ListItemNode should always have a ListNode for a parent. - let listNodeParent = parent.getParentOrThrow(); - let indentLevel = 0; - while ($isListItemNode(listNodeParent)) { - listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow(); - indentLevel++; - } - - return indentLevel; - } - - setIndent(indent: number): this { - invariant(typeof indent === 'number', 'Invalid indent value.'); - indent = Math.floor(indent); - invariant(indent >= 0, 'Indent value must be non-negative.'); - let currentIndent = this.getIndent(); - while (currentIndent !== indent) { - if (currentIndent < indent) { - $handleIndent(this); - currentIndent++; - } else { - $handleOutdent(this); - currentIndent--; - } - } - - return this; - } - /** @deprecated @internal */ canInsertAfter(node: LexicalNode): boolean { return $isListItemNode(node); diff --git a/resources/js/wysiwyg/lexical/list/formatList.ts b/resources/js/wysiwyg/lexical/list/formatList.ts index b9ca011696a..aa0d5d61129 100644 --- a/resources/js/wysiwyg/lexical/list/formatList.ts +++ b/resources/js/wysiwyg/lexical/list/formatList.ts @@ -84,10 +84,6 @@ export function insertList(editor: LexicalEditor, listType: ListType): void { if ($isRootOrShadowRoot(anchorNodeParent)) { anchorNode.replace(list); const listItem = $createListItemNode(); - if ($isElementNode(anchorNode)) { - listItem.setFormat(anchorNode.getFormatType()); - listItem.setIndent(anchorNode.getIndent()); - } list.append(listItem); } else if ($isListItemNode(anchorNode)) { const parent = anchorNode.getParentOrThrow(); @@ -157,8 +153,6 @@ function $createListOrMerge(node: ElementNode, listType: ListType): ListNode { const previousSibling = node.getPreviousSibling(); const nextSibling = node.getNextSibling(); const listItem = $createListItemNode(); - listItem.setFormat(node.getFormatType()); - listItem.setIndent(node.getIndent()); append(listItem, node.getChildren()); if ( diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts index d937060c658..bc5c3f1d207 100644 --- a/resources/js/wysiwyg/lexical/rich-text/index.ts +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -155,9 +155,6 @@ export class QuoteNode extends ElementNode { if (this.isEmpty()) { element.append(document.createElement('br')); } - - const formatType = this.getFormatType(); - element.style.textAlign = formatType; } return { @@ -167,8 +164,6 @@ export class QuoteNode extends ElementNode { static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { const node = $createQuoteNode(); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); return node; } @@ -315,9 +310,6 @@ export class HeadingNode extends ElementNode { if (this.isEmpty()) { element.append(document.createElement('br')); } - - const formatType = this.getFormatType(); - element.style.textAlign = formatType; } return { @@ -326,10 +318,7 @@ export class HeadingNode extends ElementNode { } static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { - const node = $createHeadingNode(serializedNode.tag); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); - return node; + return $createHeadingNode(serializedNode.tag); } exportJSON(): SerializedHeadingNode { @@ -402,18 +391,12 @@ function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { nodeName === 'h6' ) { node = $createHeadingNode(nodeName); - if (element.style !== null) { - node.setFormat(element.style.textAlign as ElementFormatType); - } } return {node}; } function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { const node = $createQuoteNode(); - if (element.style !== null) { - node.setFormat(element.style.textAlign as ElementFormatType); - } return {node}; } @@ -651,9 +634,6 @@ export function registerRichText(editor: LexicalEditor): () => void { (parentNode): parentNode is ElementNode => $isElementNode(parentNode) && !parentNode.isInline(), ); - if (element !== null) { - element.setFormat(format); - } } return true; }, @@ -691,28 +671,6 @@ export function registerRichText(editor: LexicalEditor): () => void { }, COMMAND_PRIORITY_EDITOR, ), - editor.registerCommand( - INDENT_CONTENT_COMMAND, - () => { - return $handleIndentAndOutdent((block) => { - const indent = block.getIndent(); - block.setIndent(indent + 1); - }); - }, - COMMAND_PRIORITY_EDITOR, - ), - editor.registerCommand( - OUTDENT_CONTENT_COMMAND, - () => { - return $handleIndentAndOutdent((block) => { - const indent = block.getIndent(); - if (indent > 0) { - block.setIndent(indent - 1); - } - }); - }, - COMMAND_PRIORITY_EDITOR, - ), editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => { @@ -846,19 +804,7 @@ export function registerRichText(editor: LexicalEditor): () => void { return false; } event.preventDefault(); - const {anchor} = selection; - const anchorNode = anchor.getNode(); - if ( - selection.isCollapsed() && - anchor.offset === 0 && - !$isRootNode(anchorNode) - ) { - const element = $getNearestBlockElementAncestorOrThrow(anchorNode); - if (element.getIndent() > 0) { - return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); - } - } return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true); }, COMMAND_PRIORITY_EDITOR, diff --git a/resources/js/wysiwyg/lexical/selection/range-selection.ts b/resources/js/wysiwyg/lexical/selection/range-selection.ts index dbadaf346b6..542eae4db9f 100644 --- a/resources/js/wysiwyg/lexical/selection/range-selection.ts +++ b/resources/js/wysiwyg/lexical/selection/range-selection.ts @@ -81,8 +81,6 @@ export function $setBlocksType( invariant($isElementNode(node), 'Expected block node to be an ElementNode'); const targetElement = createElement(); - targetElement.setFormat(node.getFormatType()); - targetElement.setIndent(node.getIndent()); node.replace(targetElement, true); } } @@ -136,8 +134,6 @@ export function $wrapNodes( : anchor.getNode(); const children = target.getChildren(); let element = createElement(); - element.setFormat(target.getFormatType()); - element.setIndent(target.getIndent()); children.forEach((child) => element.append(child)); if (wrappingElement) { @@ -277,8 +273,6 @@ export function $wrapNodesImpl( if (elementMapping.get(parentKey) === undefined) { const targetElement = createElement(); - targetElement.setFormat(parent.getFormatType()); - targetElement.setIndent(parent.getIndent()); elements.push(targetElement); elementMapping.set(parentKey, targetElement); // Move node and its siblings to the new @@ -299,8 +293,6 @@ export function $wrapNodesImpl( 'Expected node in emptyElements to be an ElementNode', ); const targetElement = createElement(); - targetElement.setFormat(node.getFormatType()); - targetElement.setIndent(node.getIndent()); elements.push(targetElement); node.remove(true); } diff --git a/resources/js/wysiwyg/nodes/custom-paragraph.ts b/resources/js/wysiwyg/nodes/custom-paragraph.ts deleted file mode 100644 index 3adc10d0e9c..00000000000 --- a/resources/js/wysiwyg/nodes/custom-paragraph.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { - DOMConversion, - DOMConversionMap, - DOMConversionOutput, - LexicalNode, - ParagraphNode, SerializedParagraphNode, Spread, -} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; -import { - CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - -export type SerializedCustomParagraphNode = Spread - -export class CustomParagraphNode extends ParagraphNode { - __id: string = ''; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-paragraph'; - } - - setId(id: string) { - const self = this.getWritable(); - self.__id = id; - } - - getId(): string { - const self = this.getLatest(); - return self.__id; - } - - setAlignment(alignment: CommonBlockAlignment) { - const self = this.getWritable(); - self.__alignment = alignment; - } - - getAlignment(): CommonBlockAlignment { - const self = this.getLatest(); - return self.__alignment; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - static clone(node: CustomParagraphNode): CustomParagraphNode { - const newNode = new CustomParagraphNode(node.__key); - newNode.__id = node.__id; - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - updateElementWithCommonBlockProps(dom, this); - return dom; - } - - updateDOM(prevNode: CustomParagraphNode, dom: HTMLElement, config: EditorConfig): boolean { - return super.updateDOM(prevNode, dom, config) - || commonPropertiesDifferent(prevNode, this); - } - - exportJSON(): SerializedCustomParagraphNode { - return { - ...super.exportJSON(), - type: 'custom-paragraph', - version: 1, - id: this.__id, - alignment: this.__alignment, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomParagraphNode): CustomParagraphNode { - const node = $createCustomParagraphNode(); - deserializeCommonBlockNode(serializedNode, node); - return node; - } - - static importDOM(): DOMConversionMap|null { - return { - p(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - const node = $createCustomParagraphNode(); - if (element.style.textIndent) { - const indent = parseInt(element.style.textIndent, 10) / 20; - if (indent > 0) { - node.setIndent(indent); - } - } - - setCommonBlockPropsFromElement(element, node); - - return {node}; - }, - priority: 1, - }; - }, - }; - } -} - -export function $createCustomParagraphNode(): CustomParagraphNode { - return new CustomParagraphNode(); -} - -export function $isCustomParagraphNode(node: LexicalNode | null | undefined): node is CustomParagraphNode { - return node instanceof CustomParagraphNode; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index b5483c5009c..062394a9887 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -7,7 +7,6 @@ import { LexicalNodeReplacement, NodeMutation, ParagraphNode } from "lexical"; -import {CustomParagraphNode} from "./custom-paragraph"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "./image"; import {DetailsNode, SummaryNode} from "./details"; @@ -45,14 +44,8 @@ export function getNodesForPageEditor(): (KlassConstructor | CodeBlockNode, DiagramNode, MediaNode, // TODO - Alignment - CustomParagraphNode, + ParagraphNode, LinkNode, - { - replace: ParagraphNode, - with: (node: ParagraphNode) => { - return new CustomParagraphNode(); - } - }, { replace: HeadingNode, with: (node: HeadingNode) => { diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index 07e35d4438e..e049d5e7c9e 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -1,4 +1,5 @@ import { + $createParagraphNode, $insertNodes, $isDecoratorNode, COMMAND_PRIORITY_HIGH, DROP_COMMAND, LexicalEditor, @@ -8,7 +9,6 @@ import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selec import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; import {Clipboard} from "../../services/clipboard"; import {$createImageNode} from "../nodes/image"; -import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$createLinkNode} from "@lexical/link"; import {EditorImageData, uploadImageFile} from "../utils/images"; import {EditorUiContext} from "../ui/framework/core"; @@ -67,7 +67,7 @@ function handleMediaInsert(data: DataTransfer, context: EditorUiContext): boolea for (const imageFile of images) { const loadingImage = window.baseUrl('/loading.gif'); const loadingNode = $createImageNode(loadingImage); - const imageWrap = $createCustomParagraphNode(); + const imageWrap = $createParagraphNode(); imageWrap.append(loadingNode); $insertNodes([imageWrap]); diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 2c7bfdbbae7..3f0b0c495e0 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -1,5 +1,6 @@ import {EditorUiContext} from "../ui/framework/core"; import { + $createParagraphNode, $getSelection, $isDecoratorNode, COMMAND_PRIORITY_LOW, @@ -13,7 +14,6 @@ import {$isImageNode} from "../nodes/image"; import {$isMediaNode} from "../nodes/media"; import {getLastSelection} from "../utils/selection"; import {$getNearestNodeBlockParent} from "../utils/nodes"; -import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$isCustomListItemNode} from "../nodes/custom-list-item"; import {$setInsetForSelection} from "../utils/lists"; @@ -45,7 +45,7 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve if (nearestBlock) { requestAnimationFrame(() => { editor.update(() => { - const newParagraph = $createCustomParagraphNode(); + const newParagraph = $createParagraphNode(); nearestBlock.insertAfter(newParagraph); newParagraph.select(); }); diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index a49cccd26dc..817a235a712 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,7 +2,11 @@ ## In progress -// +Reorg + - Merge custom nodes into original nodes + - Reduce down to use CommonBlockNode where possible + - Remove existing formatType/ElementFormatType references (replaced with alignment). + - Remove existing indent references (replaced with inset). ## Main Todo diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 0ec9220dd2e..3cfc964423f 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -1,5 +1,13 @@ import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; -import {$createTextNode, $getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; +import { + $createParagraphNode, + $createTextNode, + $getSelection, + $insertNodes, + $isParagraphNode, + LexicalEditor, + LexicalNode +} from "lexical"; import { $getBlockElementNodesInSelection, $getNodeFromSelection, @@ -8,7 +16,6 @@ import { getLastSelection } from "./selection"; import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading"; -import {$createCustomParagraphNode, $isCustomParagraphNode} from "../nodes/custom-paragraph"; import {$createCustomQuoteNode} from "../nodes/custom-quote"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; @@ -31,7 +38,7 @@ export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagT export function toggleSelectionAsParagraph(editor: LexicalEditor) { editor.update(() => { - $toggleSelectionBlockNodeType($isCustomParagraphNode, $createCustomParagraphNode); + $toggleSelectionBlockNodeType($isParagraphNode, $createParagraphNode); }); } diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 2dd99d369c7..97634f96b4e 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -1,4 +1,5 @@ import { + $createParagraphNode, $getRoot, $isDecoratorNode, $isElementNode, $isRootNode, @@ -8,7 +9,6 @@ import { LexicalNode } from "lexical"; import {LexicalNodeMatcher} from "../nodes"; -import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; import {NodeHasAlignment, NodeHasInset} from "../nodes/_common"; @@ -17,7 +17,7 @@ import {$findMatchingParent} from "@lexical/utils"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { return nodes.map(node => { if ($isTextNode(node)) { - const paragraph = $createCustomParagraphNode(); + const paragraph = $createParagraphNode(); paragraph.append(node); return paragraph; } diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 67c2d91b26c..02838eba034 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -7,17 +7,15 @@ import { $isTextNode, $setSelection, BaseSelection, DecoratorNode, - ElementFormatType, ElementNode, LexicalEditor, LexicalNode, TextFormatType, TextNode } from "lexical"; -import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; +import {$getNearestBlockElementAncestorOrThrow} from "@lexical/utils"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; -import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {CommonBlockAlignment} from "../nodes/_common"; const lastSelectionByEditor = new WeakMap; @@ -71,7 +69,7 @@ export function $toggleSelectionBlockNodeType(matcher: LexicalNodeMatcher, creat const selection = $getSelection(); const blockElement = selection ? $getNearestBlockElementAncestorOrThrow(selection.getNodes()[0]) : null; if (selection && matcher(blockElement)) { - $setBlocksType(selection, $createCustomParagraphNode); + $setBlocksType(selection, $createParagraphNode); } else { $setBlocksType(selection, creator); } From 36a4d791205f824ce6d7d487ab4578ae736c78c0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 17:04:50 +0000 Subject: [PATCH 58/72] Lexical: Extracted & merged heading & quote nodes --- .../unit/LexicalSerialization.test.ts | 3 +- .../lexical/core/__tests__/utils/index.ts | 3 +- .../lexical/core/nodes/CommonBlockNode.ts | 2 +- .../__tests__/unit/LexicalTabNode.test.ts | 3 +- .../html/__tests__/unit/LexicalHtml.test.ts | 3 +- .../lexical/rich-text/LexicalHeadingNode.ts | 202 ++++++++++ .../lexical/rich-text/LexicalQuoteNode.ts | 129 +++++++ .../__tests__/unit/LexicalHeadingNode.test.ts | 6 +- .../__tests__/unit/LexicalQuoteNode.test.ts | 2 +- .../js/wysiwyg/lexical/rich-text/index.ts | 345 +----------------- .../__tests__/unit/LexicalSelection.test.ts | 3 +- .../unit/LexicalSelectionHelpers.test.ts | 2 +- .../unit/LexicalEventHelpers.test.ts | 4 +- resources/js/wysiwyg/nodes/custom-heading.ts | 146 -------- resources/js/wysiwyg/nodes/custom-quote.ts | 115 ------ resources/js/wysiwyg/nodes/index.ts | 21 +- resources/js/wysiwyg/services/shortcuts.ts | 2 +- .../ui/defaults/buttons/block-formats.ts | 8 +- .../wysiwyg/ui/framework/blocks/link-field.ts | 11 +- resources/js/wysiwyg/utils/formats.ts | 11 +- 20 files changed, 370 insertions(+), 651 deletions(-) create mode 100644 resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts create mode 100644 resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-heading.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-quote.ts diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts index 5599604c059..81eff674a99 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -8,11 +8,12 @@ import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text'; import {$createTableNodeWithDimensions} from '@lexical/table'; import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; import {initializeUnitTest} from '../utils'; +import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {$createQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; function $createEditorContent() { const root = $getRoot(); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index a4d74210e41..e9d14ef1139 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -10,7 +10,6 @@ import {createHeadlessEditor} from '@lexical/headless'; import {AutoLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; -import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import { @@ -36,6 +35,8 @@ import { LexicalNodeReplacement, } from '../../LexicalEditor'; import {resetRandomKey} from '../../LexicalUtils'; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; type TestEnv = { diff --git a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts index 37ca1cdef7f..bf4fc08ca60 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts @@ -48,7 +48,7 @@ export class CommonBlockNode extends ElementNode { } export function copyCommonBlockProperties(from: CommonBlockNode, to: CommonBlockNode): void { - to.__id = from.__id; + // to.__id = from.__id; to.__alignment = from.__alignment; to.__inset = from.__inset; } \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts index d8525fb369f..9831114340d 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts @@ -11,7 +11,7 @@ import { $insertDataTransferForRichText, } from '@lexical/clipboard'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; +import {registerRichText} from '@lexical/rich-text'; import { $createParagraphNode, $createRangeSelection, @@ -32,6 +32,7 @@ import { initializeUnitTest, invariant, } from '../../../__tests__/utils'; +import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; describe('LexicalTabNode tests', () => { initializeUnitTest((testEnv) => { diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts index 947e591b4ff..a4e2d231389 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -13,13 +13,14 @@ import {createHeadlessEditor} from '@lexical/headless'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; import {LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; -import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import { $createParagraphNode, $createRangeSelection, $createTextNode, $getRoot, } from 'lexical'; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; describe('HTML', () => { type Input = Array<{ diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts new file mode 100644 index 00000000000..0f30263ba0e --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts @@ -0,0 +1,202 @@ +import { + $applyNodeReplacement, + $createParagraphNode, + type DOMConversionMap, + DOMConversionOutput, + type DOMExportOutput, + type EditorConfig, + isHTMLElement, + type LexicalEditor, + type LexicalNode, + type NodeKey, + type ParagraphNode, + type RangeSelection, + type SerializedElementNode, + type Spread +} from "lexical"; +import {addClassNamesToElement} from "@lexical/utils"; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../nodes/_common"; + +export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export type SerializedHeadingNode = Spread< + { + tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + }, + SerializedCommonBlockNode +>; + +/** @noInheritDoc */ +export class HeadingNode extends CommonBlockNode { + /** @internal */ + __tag: HeadingTagType; + + static getType(): string { + return 'heading'; + } + + static clone(node: HeadingNode): HeadingNode { + const clone = new HeadingNode(node.__tag, node.__key); + copyCommonBlockProperties(node, clone); + return clone; + } + + constructor(tag: HeadingTagType, key?: NodeKey) { + super(key); + this.__tag = tag; + } + + getTag(): HeadingTagType { + return this.__tag; + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const tag = this.__tag; + const element = document.createElement(tag); + const theme = config.theme; + const classNames = theme.heading; + if (classNames !== undefined) { + const className = classNames[tag]; + addClassNamesToElement(element, className); + } + updateElementWithCommonBlockProps(element, this); + return element; + } + + updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { + return commonPropertiesDifferent(prevNode, this); + } + + static importDOM(): DOMConversionMap | null { + return { + h1: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h2: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h3: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h4: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h5: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + h6: (node: Node) => ({ + conversion: $convertHeadingElement, + priority: 0, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element && isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { + const node = $createHeadingNode(serializedNode.tag); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + + exportJSON(): SerializedHeadingNode { + return { + ...super.exportJSON(), + tag: this.getTag(), + type: 'heading', + version: 1, + }; + } + + // Mutation + insertNewAfter( + selection?: RangeSelection, + restoreSelection = true, + ): ParagraphNode | HeadingNode { + const anchorOffet = selection ? selection.anchor.offset : 0; + const lastDesc = this.getLastDescendant(); + const isAtEnd = + !lastDesc || + (selection && + selection.anchor.key === lastDesc.getKey() && + anchorOffet === lastDesc.getTextContentSize()); + const newElement = + isAtEnd || !selection + ? $createParagraphNode() + : $createHeadingNode(this.getTag()); + const direction = this.getDirection(); + newElement.setDirection(direction); + this.insertAfter(newElement, restoreSelection); + if (anchorOffet === 0 && !this.isEmpty() && selection) { + const paragraph = $createParagraphNode(); + paragraph.select(); + this.replace(paragraph, true); + } + return newElement; + } + + collapseAtStart(): true { + const newElement = !this.isEmpty() + ? $createHeadingNode(this.getTag()) + : $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => newElement.append(child)); + this.replace(newElement); + return true; + } + + extractWithChild(): boolean { + return true; + } +} + +function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { + const nodeName = element.nodeName.toLowerCase(); + let node = null; + if ( + nodeName === 'h1' || + nodeName === 'h2' || + nodeName === 'h3' || + nodeName === 'h4' || + nodeName === 'h5' || + nodeName === 'h6' + ) { + node = $createHeadingNode(nodeName); + setCommonBlockPropsFromElement(element, node); + } + return {node}; +} + +export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode { + return $applyNodeReplacement(new HeadingNode(headingTag)); +} + +export function $isHeadingNode( + node: LexicalNode | null | undefined, +): node is HeadingNode { + return node instanceof HeadingNode; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts new file mode 100644 index 00000000000..53caca80115 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts @@ -0,0 +1,129 @@ +import { + $applyNodeReplacement, + $createParagraphNode, + type DOMConversionMap, + type DOMConversionOutput, + type DOMExportOutput, + type EditorConfig, + ElementNode, + isHTMLElement, + type LexicalEditor, + LexicalNode, + type NodeKey, + type ParagraphNode, + type RangeSelection, + SerializedElementNode +} from "lexical"; +import {addClassNamesToElement} from "@lexical/utils"; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../nodes/_common"; + +export type SerializedQuoteNode = SerializedCommonBlockNode; + +/** @noInheritDoc */ +export class QuoteNode extends CommonBlockNode { + static getType(): string { + return 'quote'; + } + + static clone(node: QuoteNode): QuoteNode { + const clone = new QuoteNode(node.__key); + copyCommonBlockProperties(node, clone); + return clone; + } + + constructor(key?: NodeKey) { + super(key); + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('blockquote'); + addClassNamesToElement(element, config.theme.quote); + updateElementWithCommonBlockProps(element, this); + return element; + } + + updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { + return commonPropertiesDifferent(prevNode, this); + } + + static importDOM(): DOMConversionMap | null { + return { + blockquote: (node: Node) => ({ + conversion: $convertBlockquoteElement, + priority: 0, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (element && isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + } + + return { + element, + }; + } + + static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { + const node = $createQuoteNode(); + deserializeCommonBlockNode(serializedNode, node); + return node; + } + + exportJSON(): SerializedQuoteNode { + return { + ...super.exportJSON(), + type: 'quote', + }; + } + + // Mutation + + insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode { + const newBlock = $createParagraphNode(); + const direction = this.getDirection(); + newBlock.setDirection(direction); + this.insertAfter(newBlock, restoreSelection); + return newBlock; + } + + collapseAtStart(): true { + const paragraph = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => paragraph.append(child)); + this.replace(paragraph); + return true; + } + + canMergeWhenEmpty(): true { + return true; + } +} + +export function $createQuoteNode(): QuoteNode { + return $applyNodeReplacement(new QuoteNode()); +} + +export function $isQuoteNode( + node: LexicalNode | null | undefined, +): node is QuoteNode { + return node instanceof QuoteNode; +} + +function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { + const node = $createQuoteNode(); + setCommonBlockPropsFromElement(element, node); + return {node}; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts index a94f9ee0bb8..be4b97ba3f3 100644 --- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalHeadingNode.test.ts @@ -6,11 +6,6 @@ * */ -import { - $createHeadingNode, - $isHeadingNode, - HeadingNode, -} from '@lexical/rich-text'; import { $createTextNode, $getRoot, @@ -19,6 +14,7 @@ import { RangeSelection, } from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; +import {$createHeadingNode, $isHeadingNode, HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts index 66374bf5ff0..cf85045cda1 100644 --- a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalQuoteNode.test.ts @@ -6,9 +6,9 @@ * */ -import {$createQuoteNode, QuoteNode} from '@lexical/rich-text'; import {$createRangeSelection, $getRoot, ParagraphNode} from 'lexical'; import {initializeUnitTest} from 'lexical/__tests__/utils'; +import {$createQuoteNode, QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/lexical/rich-text/index.ts b/resources/js/wysiwyg/lexical/rich-text/index.ts index bc5c3f1d207..c585c028a5a 100644 --- a/resources/js/wysiwyg/lexical/rich-text/index.ts +++ b/resources/js/wysiwyg/lexical/rich-text/index.ts @@ -8,42 +8,14 @@ import type { CommandPayloadType, - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, ElementFormatType, LexicalCommand, LexicalEditor, - LexicalNode, - NodeKey, - ParagraphNode, PasteCommandType, RangeSelection, - SerializedElementNode, - Spread, TextFormatType, } from 'lexical'; - -import { - $insertDataTransferForRichText, - copyToClipboard, -} from '@lexical/clipboard'; -import { - $moveCharacter, - $shouldOverrideDefaultCharacterSelection, -} from '@lexical/selection'; import { - $findMatchingParent, - $getNearestBlockElementAncestorOrThrow, - addClassNamesToElement, - isHTMLElement, - mergeRegister, - objectKlassEquals, -} from '@lexical/utils'; -import { - $applyNodeReplacement, - $createParagraphNode, $createRangeSelection, $createTabNode, $getAdjacentNode, @@ -55,7 +27,6 @@ import { $isElementNode, $isNodeSelection, $isRangeSelection, - $isRootNode, $isTextNode, $normalizeSelection__EXPERIMENTAL, $selectAll, @@ -75,7 +46,6 @@ import { ElementNode, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, - INDENT_CONTENT_COMMAND, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, INSERT_TAB_COMMAND, @@ -88,327 +58,22 @@ import { KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, - OUTDENT_CONTENT_COMMAND, PASTE_COMMAND, REMOVE_TEXT_COMMAND, SELECT_ALL_COMMAND, } from 'lexical'; -import caretFromPoint from 'lexical/shared/caretFromPoint'; -import { - CAN_USE_BEFORE_INPUT, - IS_APPLE_WEBKIT, - IS_IOS, - IS_SAFARI, -} from 'lexical/shared/environment'; -export type SerializedHeadingNode = Spread< - { - tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; - }, - SerializedElementNode ->; +import {$insertDataTransferForRichText, copyToClipboard,} from '@lexical/clipboard'; +import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexical/selection'; +import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils'; +import caretFromPoint from 'lexical/shared/caretFromPoint'; +import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment'; export const DRAG_DROP_PASTE: LexicalCommand> = createCommand( 'DRAG_DROP_PASTE_FILE', ); -export type SerializedQuoteNode = SerializedElementNode; - -/** @noInheritDoc */ -export class QuoteNode extends ElementNode { - static getType(): string { - return 'quote'; - } - - static clone(node: QuoteNode): QuoteNode { - return new QuoteNode(node.__key); - } - - constructor(key?: NodeKey) { - super(key); - } - - // View - - createDOM(config: EditorConfig): HTMLElement { - const element = document.createElement('blockquote'); - addClassNamesToElement(element, config.theme.quote); - return element; - } - updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { - return false; - } - - static importDOM(): DOMConversionMap | null { - return { - blockquote: (node: Node) => ({ - conversion: $convertBlockquoteElement, - priority: 0, - }), - }; - } - - exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element} = super.exportDOM(editor); - - if (element && isHTMLElement(element)) { - if (this.isEmpty()) { - element.append(document.createElement('br')); - } - } - - return { - element, - }; - } - - static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { - const node = $createQuoteNode(); - return node; - } - - exportJSON(): SerializedElementNode { - return { - ...super.exportJSON(), - type: 'quote', - }; - } - - // Mutation - - insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode { - const newBlock = $createParagraphNode(); - const direction = this.getDirection(); - newBlock.setDirection(direction); - this.insertAfter(newBlock, restoreSelection); - return newBlock; - } - - collapseAtStart(): true { - const paragraph = $createParagraphNode(); - const children = this.getChildren(); - children.forEach((child) => paragraph.append(child)); - this.replace(paragraph); - return true; - } - - canMergeWhenEmpty(): true { - return true; - } -} - -export function $createQuoteNode(): QuoteNode { - return $applyNodeReplacement(new QuoteNode()); -} - -export function $isQuoteNode( - node: LexicalNode | null | undefined, -): node is QuoteNode { - return node instanceof QuoteNode; -} - -export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; - -/** @noInheritDoc */ -export class HeadingNode extends ElementNode { - /** @internal */ - __tag: HeadingTagType; - - static getType(): string { - return 'heading'; - } - - static clone(node: HeadingNode): HeadingNode { - return new HeadingNode(node.__tag, node.__key); - } - - constructor(tag: HeadingTagType, key?: NodeKey) { - super(key); - this.__tag = tag; - } - - getTag(): HeadingTagType { - return this.__tag; - } - - // View - - createDOM(config: EditorConfig): HTMLElement { - const tag = this.__tag; - const element = document.createElement(tag); - const theme = config.theme; - const classNames = theme.heading; - if (classNames !== undefined) { - const className = classNames[tag]; - addClassNamesToElement(element, className); - } - return element; - } - - updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { - return false; - } - - static importDOM(): DOMConversionMap | null { - return { - h1: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h2: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h3: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h4: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h5: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h6: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - p: (node: Node) => { - // domNode is a

    since we matched it by nodeName - const paragraph = node as HTMLParagraphElement; - const firstChild = paragraph.firstChild; - if (firstChild !== null && isGoogleDocsTitle(firstChild)) { - return { - conversion: () => ({node: null}), - priority: 3, - }; - } - return null; - }, - span: (node: Node) => { - if (isGoogleDocsTitle(node)) { - return { - conversion: (domNode: Node) => { - return { - node: $createHeadingNode('h1'), - }; - }, - priority: 3, - }; - } - return null; - }, - }; - } - - exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element} = super.exportDOM(editor); - - if (element && isHTMLElement(element)) { - if (this.isEmpty()) { - element.append(document.createElement('br')); - } - } - - return { - element, - }; - } - - static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { - return $createHeadingNode(serializedNode.tag); - } - - exportJSON(): SerializedHeadingNode { - return { - ...super.exportJSON(), - tag: this.getTag(), - type: 'heading', - version: 1, - }; - } - - // Mutation - insertNewAfter( - selection?: RangeSelection, - restoreSelection = true, - ): ParagraphNode | HeadingNode { - const anchorOffet = selection ? selection.anchor.offset : 0; - const lastDesc = this.getLastDescendant(); - const isAtEnd = - !lastDesc || - (selection && - selection.anchor.key === lastDesc.getKey() && - anchorOffet === lastDesc.getTextContentSize()); - const newElement = - isAtEnd || !selection - ? $createParagraphNode() - : $createHeadingNode(this.getTag()); - const direction = this.getDirection(); - newElement.setDirection(direction); - this.insertAfter(newElement, restoreSelection); - if (anchorOffet === 0 && !this.isEmpty() && selection) { - const paragraph = $createParagraphNode(); - paragraph.select(); - this.replace(paragraph, true); - } - return newElement; - } - - collapseAtStart(): true { - const newElement = !this.isEmpty() - ? $createHeadingNode(this.getTag()) - : $createParagraphNode(); - const children = this.getChildren(); - children.forEach((child) => newElement.append(child)); - this.replace(newElement); - return true; - } - - extractWithChild(): boolean { - return true; - } -} -function isGoogleDocsTitle(domNode: Node): boolean { - if (domNode.nodeName.toLowerCase() === 'span') { - return (domNode as HTMLSpanElement).style.fontSize === '26pt'; - } - return false; -} - -function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { - const nodeName = element.nodeName.toLowerCase(); - let node = null; - if ( - nodeName === 'h1' || - nodeName === 'h2' || - nodeName === 'h3' || - nodeName === 'h4' || - nodeName === 'h5' || - nodeName === 'h6' - ) { - node = $createHeadingNode(nodeName); - } - return {node}; -} - -function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { - const node = $createQuoteNode(); - return {node}; -} - -export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode { - return $applyNodeReplacement(new HeadingNode(headingTag)); -} - -export function $isHeadingNode( - node: LexicalNode | null | undefined, -): node is HeadingNode { - return node instanceof HeadingNode; -} function onPasteForRichText( event: CommandPayloadType, diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts index 5f2d9dcc093..466be7498de 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -8,7 +8,7 @@ import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode} from '@lexical/list'; -import {$createHeadingNode, registerRichText} from '@lexical/rich-text'; +import {registerRichText} from '@lexical/rich-text'; import { $addNodeStyle, $getSelectionStyleValueForProperty, @@ -74,6 +74,7 @@ import { } from '../utils'; import {createEmptyHistoryState, registerHistory} from "@lexical/history"; import {mergeRegister} from "@lexical/utils"; +import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; interface ExpectedSelection { anchorPath: number[]; diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts index 4d88bde0e4c..0523b7f7164 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelectionHelpers.test.ts @@ -7,7 +7,6 @@ */ import {$createLinkNode} from '@lexical/link'; -import {$createHeadingNode, $isHeadingNode} from '@lexical/rich-text'; import { $getSelectionStyleValueForProperty, $patchStyleText, @@ -44,6 +43,7 @@ import { } from 'lexical/__tests__/utils'; import {$setAnchorPoint, $setFocusPoint} from '../utils'; +import {$createHeadingNode, $isHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; Range.prototype.getBoundingClientRect = function (): DOMRect { const rect = { diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts index fd7731f9061..d76937ed606 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -7,7 +7,7 @@ */ import {AutoLinkNode, LinkNode} from '@lexical/link'; import {ListItemNode, ListNode} from '@lexical/list'; -import {HeadingNode, QuoteNode, registerRichText} from '@lexical/rich-text'; +import {registerRichText} from '@lexical/rich-text'; import { applySelectionInputs, pasteHTML, @@ -15,6 +15,8 @@ import { import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import {$createParagraphNode, $insertNodes, LexicalEditor} from 'lexical'; import {createTestEditor, initializeClipboard} from 'lexical/__tests__/utils'; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; jest.mock('lexical/shared/environment', () => { const originalModule = jest.requireActual('lexical/shared/environment'); diff --git a/resources/js/wysiwyg/nodes/custom-heading.ts b/resources/js/wysiwyg/nodes/custom-heading.ts deleted file mode 100644 index 5df6245f5a5..00000000000 --- a/resources/js/wysiwyg/nodes/custom-heading.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { - DOMConversionMap, - DOMConversionOutput, - LexicalNode, - Spread -} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; -import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text"; -import { - CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - - -export type SerializedCustomHeadingNode = Spread - -export class CustomHeadingNode extends HeadingNode { - __id: string = ''; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-heading'; - } - - setId(id: string) { - const self = this.getWritable(); - self.__id = id; - } - - getId(): string { - const self = this.getLatest(); - return self.__id; - } - - setAlignment(alignment: CommonBlockAlignment) { - const self = this.getWritable(); - self.__alignment = alignment; - } - - getAlignment(): CommonBlockAlignment { - const self = this.getLatest(); - return self.__alignment; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - static clone(node: CustomHeadingNode) { - const newNode = new CustomHeadingNode(node.__tag, node.__key); - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - updateElementWithCommonBlockProps(dom, this); - return dom; - } - - updateDOM(prevNode: CustomHeadingNode, dom: HTMLElement): boolean { - return super.updateDOM(prevNode, dom) - || commonPropertiesDifferent(prevNode, this); - } - - exportJSON(): SerializedCustomHeadingNode { - return { - ...super.exportJSON(), - type: 'custom-heading', - version: 1, - id: this.__id, - alignment: this.__alignment, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode { - const node = $createCustomHeadingNode(serializedNode.tag); - deserializeCommonBlockNode(serializedNode, node); - return node; - } - - static importDOM(): DOMConversionMap | null { - return { - h1: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h2: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h3: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h4: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h5: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - h6: (node: Node) => ({ - conversion: $convertHeadingElement, - priority: 0, - }), - }; - } -} - -function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { - const nodeName = element.nodeName.toLowerCase(); - let node = null; - if ( - nodeName === 'h1' || - nodeName === 'h2' || - nodeName === 'h3' || - nodeName === 'h4' || - nodeName === 'h5' || - nodeName === 'h6' - ) { - node = $createCustomHeadingNode(nodeName); - setCommonBlockPropsFromElement(element, node); - } - return {node}; -} - -export function $createCustomHeadingNode(tag: HeadingTagType) { - return new CustomHeadingNode(tag); -} - -export function $isCustomHeadingNode(node: LexicalNode | null | undefined): node is CustomHeadingNode { - return node instanceof CustomHeadingNode; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-quote.ts b/resources/js/wysiwyg/nodes/custom-quote.ts deleted file mode 100644 index 39ae7bf8af3..00000000000 --- a/resources/js/wysiwyg/nodes/custom-quote.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - DOMConversionMap, - DOMConversionOutput, - LexicalNode, - Spread -} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; -import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text"; -import { - CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - - -export type SerializedCustomQuoteNode = Spread - -export class CustomQuoteNode extends QuoteNode { - __id: string = ''; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-quote'; - } - - setId(id: string) { - const self = this.getWritable(); - self.__id = id; - } - - getId(): string { - const self = this.getLatest(); - return self.__id; - } - - setAlignment(alignment: CommonBlockAlignment) { - const self = this.getWritable(); - self.__alignment = alignment; - } - - getAlignment(): CommonBlockAlignment { - const self = this.getLatest(); - return self.__alignment; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - static clone(node: CustomQuoteNode) { - const newNode = new CustomQuoteNode(node.__key); - newNode.__id = node.__id; - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - updateElementWithCommonBlockProps(dom, this); - return dom; - } - - updateDOM(prevNode: CustomQuoteNode): boolean { - return commonPropertiesDifferent(prevNode, this); - } - - exportJSON(): SerializedCustomQuoteNode { - return { - ...super.exportJSON(), - type: 'custom-quote', - version: 1, - id: this.__id, - alignment: this.__alignment, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode { - const node = $createCustomQuoteNode(); - deserializeCommonBlockNode(serializedNode, node); - return node; - } - - static importDOM(): DOMConversionMap | null { - return { - blockquote: (node: Node) => ({ - conversion: $convertBlockquoteElement, - priority: 0, - }), - }; - } -} - -function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { - const node = $createCustomQuoteNode(); - setCommonBlockPropsFromElement(element, node); - return {node}; -} - -export function $createCustomQuoteNode() { - return new CustomQuoteNode(); -} - -export function $isCustomQuoteNode(node: LexicalNode | null | undefined): node is CustomQuoteNode { - return node instanceof CustomQuoteNode; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 062394a9887..7b274eba13c 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,4 +1,3 @@ -import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; import { ElementNode, @@ -21,9 +20,9 @@ import {MediaNode} from "./media"; import {CustomListItemNode} from "./custom-list-item"; import {CustomTableCellNode} from "./custom-table-cell"; import {CustomTableRowNode} from "./custom-table-row"; -import {CustomHeadingNode} from "./custom-heading"; -import {CustomQuoteNode} from "./custom-quote"; import {CustomListNode} from "./custom-list"; +import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; +import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; /** * Load the nodes for lexical. @@ -31,8 +30,8 @@ import {CustomListNode} from "./custom-list"; export function getNodesForPageEditor(): (KlassConstructor | LexicalNodeReplacement)[] { return [ CalloutNode, - CustomHeadingNode, - CustomQuoteNode, + HeadingNode, + QuoteNode, CustomListNode, CustomListItemNode, // TODO - Alignment? CustomTableNode, @@ -46,18 +45,6 @@ export function getNodesForPageEditor(): (KlassConstructor | MediaNode, // TODO - Alignment ParagraphNode, LinkNode, - { - replace: HeadingNode, - with: (node: HeadingNode) => { - return new CustomHeadingNode(node.__tag); - } - }, - { - replace: QuoteNode, - with: (node: QuoteNode) => { - return new CustomQuoteNode(); - } - }, { replace: ListNode, with: (node: ListNode) => { diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index 05bdb5dccd3..0384a3bf133 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -6,12 +6,12 @@ import { toggleSelectionAsHeading, toggleSelectionAsList, toggleSelectionAsParagraph } from "../utils/formats"; -import {HeadingTagType} from "@lexical/rich-text"; import {EditorUiContext} from "../ui/framework/core"; import {$getNodeFromSelection} from "../utils/selection"; import {$isLinkNode, LinkNode} from "@lexical/link"; import {$showLinkForm} from "../ui/defaults/forms/objects"; import {showLinkSelector} from "../utils/links"; +import {HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; function headerHandler(editor: LexicalEditor, tag: HeadingTagType): boolean { toggleSelectionAsHeading(editor, tag); diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index f86e33c311e..e0d1e7077fa 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -2,18 +2,14 @@ import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../ import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; -import { - $isHeadingNode, - $isQuoteNode, - HeadingNode, - HeadingTagType -} from "@lexical/rich-text"; import {$selectionContainsNodeType, $toggleSelectionBlockNodeType} from "../../../utils/selection"; import { toggleSelectionAsBlockquote, toggleSelectionAsHeading, toggleSelectionAsParagraph } from "../../../utils/formats"; +import {$isHeadingNode, HeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; +import {$isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; function buildCalloutButton(category: CalloutCategory, name: string): EditorButtonDefinition { return { diff --git a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts index 5a64cdc30fc..f88b22c3f05 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/link-field.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/link-field.ts @@ -1,14 +1,13 @@ import {EditorContainerUiElement} from "../core"; import {el} from "../../../utils/dom"; import {EditorFormField} from "../forms"; -import {CustomHeadingNode} from "../../../nodes/custom-heading"; import {$getAllNodesOfType} from "../../../utils/nodes"; -import {$isHeadingNode} from "@lexical/rich-text"; import {uniqueIdSmall} from "../../../../services/util"; +import {$isHeadingNode, HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; export class LinkField extends EditorContainerUiElement { protected input: EditorFormField; - protected headerMap = new Map(); + protected headerMap = new Map(); constructor(input: EditorFormField) { super([input]); @@ -43,7 +42,7 @@ export class LinkField extends EditorContainerUiElement { return container; } - updateFormFromHeader(header: CustomHeadingNode) { + updateFormFromHeader(header: HeadingNode) { this.getHeaderIdAndText(header).then(({id, text}) => { console.log('updating form', id, text); const modal = this.getContext().manager.getActiveModal('link'); @@ -57,7 +56,7 @@ export class LinkField extends EditorContainerUiElement { }); } - getHeaderIdAndText(header: CustomHeadingNode): Promise<{id: string, text: string}> { + getHeaderIdAndText(header: HeadingNode): Promise<{id: string, text: string}> { return new Promise((res) => { this.getContext().editor.update(() => { let id = header.getId(); @@ -75,7 +74,7 @@ export class LinkField extends EditorContainerUiElement { updateDataList(listEl: HTMLElement) { this.getContext().editor.getEditorState().read(() => { - const headers = $getAllNodesOfType($isHeadingNode) as CustomHeadingNode[]; + const headers = $getAllNodesOfType($isHeadingNode) as HeadingNode[]; this.headerMap.clear(); const listEls: HTMLElement[] = []; diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 3cfc964423f..d724730e3a2 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -1,4 +1,3 @@ -import {$isQuoteNode, HeadingNode, HeadingTagType} from "@lexical/rich-text"; import { $createParagraphNode, $createTextNode, @@ -15,23 +14,23 @@ import { $toggleSelectionBlockNodeType, getLastSelection } from "./selection"; -import {$createCustomHeadingNode, $isCustomHeadingNode} from "../nodes/custom-heading"; -import {$createCustomQuoteNode} from "../nodes/custom-quote"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; import {insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$isCustomListNode} from "../nodes/custom-list"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; +import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; +import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; const $isHeaderNodeOfTag = (node: LexicalNode | null | undefined, tag: HeadingTagType) => { - return $isCustomHeadingNode(node) && (node as HeadingNode).getTag() === tag; + return $isHeadingNode(node) && node.getTag() === tag; }; export function toggleSelectionAsHeading(editor: LexicalEditor, tag: HeadingTagType) { editor.update(() => { $toggleSelectionBlockNodeType( (node) => $isHeaderNodeOfTag(node, tag), - () => $createCustomHeadingNode(tag), + () => $createHeadingNode(tag), ) }); } @@ -44,7 +43,7 @@ export function toggleSelectionAsParagraph(editor: LexicalEditor) { export function toggleSelectionAsBlockquote(editor: LexicalEditor) { editor.update(() => { - $toggleSelectionBlockNodeType($isQuoteNode, $createCustomQuoteNode); + $toggleSelectionBlockNodeType($isQuoteNode, $createQuoteNode); }); } From ebd4604f21060cd5414fda5d2d79ed41e79e9b62 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 19:03:52 +0000 Subject: [PATCH 59/72] Lexical: Merged list nodes --- .../lexical/list/LexicalListItemNode.ts | 126 ++++++---------- .../wysiwyg/lexical/list/LexicalListNode.ts | 66 ++++++--- .../unit/LexicalListItemNode.test.ts | 94 ------------ .../js/wysiwyg/nodes/custom-list-item.ts | 120 --------------- resources/js/wysiwyg/nodes/custom-list.ts | 139 ------------------ resources/js/wysiwyg/nodes/index.ts | 18 +-- .../js/wysiwyg/services/keyboard-handling.ts | 4 +- .../ui/framework/helpers/task-list-handler.ts | 4 +- resources/js/wysiwyg/utils/formats.ts | 5 +- resources/js/wysiwyg/utils/lists.ts | 27 ++-- 10 files changed, 111 insertions(+), 492 deletions(-) delete mode 100644 resources/js/wysiwyg/nodes/custom-list-item.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-list.ts diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index c20329e4be9..33b021298a6 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -13,7 +13,6 @@ import type { DOMConversionOutput, DOMExportOutput, EditorConfig, - EditorThemeClasses, LexicalNode, NodeKey, ParagraphNode, @@ -22,10 +21,6 @@ import type { Spread, } from 'lexical'; -import { - addClassNamesToElement, - removeClassNamesFromElement, -} from '@lexical/utils'; import { $applyNodeReplacement, $createParagraphNode, @@ -36,11 +31,11 @@ import { LexicalEditor, } from 'lexical'; import invariant from 'lexical/shared/invariant'; -import normalizeClassNames from 'lexical/shared/normalizeClassNames'; import {$createListNode, $isListNode} from './'; -import {$handleIndent, $handleOutdent, mergeLists} from './formatList'; +import {mergeLists} from './formatList'; import {isNestedListNode} from './utils'; +import {el} from "../../utils/dom"; export type SerializedListItemNode = Spread< { @@ -74,11 +69,17 @@ export class ListItemNode extends ElementNode { createDOM(config: EditorConfig): HTMLElement { const element = document.createElement('li'); const parent = this.getParent(); + if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(element, this, null, parent); + updateListItemChecked(element, this); } + element.value = this.__value; - $setListItemThemeClassNames(element, config.theme, this); + + if ($hasNestedListWithoutLabel(this)) { + element.style.listStyle = 'none'; + } + return element; } @@ -89,11 +90,12 @@ export class ListItemNode extends ElementNode { ): boolean { const parent = this.getParent(); if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(dom, this, prevNode, parent); + updateListItemChecked(dom, this); } + + dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : ''; // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; - $setListItemThemeClassNames(dom, config.theme, this); return false; } @@ -132,6 +134,20 @@ export class ListItemNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const element = this.createDOM(editor._config); + + if (element.classList.contains('task-list-item')) { + const input = el('input', { + type: 'checkbox', + disabled: 'disabled', + }); + if (element.hasAttribute('checked')) { + input.setAttribute('checked', 'checked'); + element.removeAttribute('checked'); + } + + element.prepend(input); + } + return { element, }; @@ -390,89 +406,33 @@ export class ListItemNode extends ElementNode { } } -function $setListItemThemeClassNames( - dom: HTMLElement, - editorThemeClasses: EditorThemeClasses, - node: ListItemNode, -): void { - const classesToAdd = []; - const classesToRemove = []; - const listTheme = editorThemeClasses.list; - const listItemClassName = listTheme ? listTheme.listitem : undefined; - let nestedListItemClassName; - - if (listTheme && listTheme.nested) { - nestedListItemClassName = listTheme.nested.listitem; - } - - if (listItemClassName !== undefined) { - classesToAdd.push(...normalizeClassNames(listItemClassName)); - } - - if (listTheme) { - const parentNode = node.getParent(); - const isCheckList = - $isListNode(parentNode) && parentNode.getListType() === 'check'; - const checked = node.getChecked(); - - if (!isCheckList || checked) { - classesToRemove.push(listTheme.listitemUnchecked); - } - - if (!isCheckList || !checked) { - classesToRemove.push(listTheme.listitemChecked); - } +function $hasNestedListWithoutLabel(node: ListItemNode): boolean { + const children = node.getChildren(); + let hasLabel = false; + let hasNestedList = false; - if (isCheckList) { - classesToAdd.push( - checked ? listTheme.listitemChecked : listTheme.listitemUnchecked, - ); + for (const child of children) { + if ($isListNode(child)) { + hasNestedList = true; + } else if (child.getTextContent().trim().length > 0) { + hasLabel = true; } } - if (nestedListItemClassName !== undefined) { - const nestedListItemClasses = normalizeClassNames(nestedListItemClassName); - - if (node.getChildren().some((child) => $isListNode(child))) { - classesToAdd.push(...nestedListItemClasses); - } else { - classesToRemove.push(...nestedListItemClasses); - } - } - - if (classesToRemove.length > 0) { - removeClassNamesFromElement(dom, ...classesToRemove); - } - - if (classesToAdd.length > 0) { - addClassNamesToElement(dom, ...classesToAdd); - } + return hasNestedList && !hasLabel; } function updateListItemChecked( dom: HTMLElement, listItemNode: ListItemNode, - prevListItemNode: ListItemNode | null, - listNode: ListNode, ): void { - // Only add attributes for leaf list items - if ($isListNode(listItemNode.getFirstChild())) { - dom.removeAttribute('role'); - dom.removeAttribute('tabIndex'); - dom.removeAttribute('aria-checked'); + // Only set task list attrs for leaf list items + const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild()); + dom.classList.toggle('task-list-item', shouldBeTaskItem); + if (listItemNode.__checked) { + dom.setAttribute('checked', 'checked'); } else { - dom.setAttribute('role', 'checkbox'); - dom.setAttribute('tabIndex', '-1'); - - if ( - !prevListItemNode || - listItemNode.__checked !== prevListItemNode.__checked - ) { - dom.setAttribute( - 'aria-checked', - listItemNode.getChecked() ? 'true' : 'false', - ); - } + dom.removeAttribute('checked'); } } diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index e22fbf7717f..138c895e6b8 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -36,9 +36,11 @@ import { updateChildrenListItemValue, } from './formatList'; import {$getListDepth, $wrapInListItem} from './utils'; +import {extractDirectionFromElement} from "../../nodes/_common"; export type SerializedListNode = Spread< { + id: string; listType: ListType; start: number; tag: ListNodeTagType; @@ -58,15 +60,18 @@ export class ListNode extends ElementNode { __start: number; /** @internal */ __listType: ListType; + /** @internal */ + __id: string = ''; static getType(): string { return 'list'; } static clone(node: ListNode): ListNode { - const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag]; - - return new ListNode(listType, node.__start, node.__key); + const newNode = new ListNode(node.__listType, node.__start, node.__key); + newNode.__id = node.__id; + newNode.__dir = node.__dir; + return newNode; } constructor(listType: ListType, start: number, key?: NodeKey) { @@ -81,6 +86,16 @@ export class ListNode extends ElementNode { return this.__tag; } + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + setListType(type: ListType): void { const writable = this.getWritable(); writable.__listType = type; @@ -108,6 +123,14 @@ export class ListNode extends ElementNode { dom.__lexicalListType = this.__listType; $setListThemeClassNames(dom, config.theme, this); + if (this.__id) { + dom.setAttribute('id', this.__id); + } + + if (this.__dir) { + dom.setAttribute('dir', this.__dir); + } + return dom; } @@ -116,7 +139,11 @@ export class ListNode extends ElementNode { dom: HTMLElement, config: EditorConfig, ): boolean { - if (prevNode.__tag !== this.__tag) { + if ( + prevNode.__tag !== this.__tag + || prevNode.__dir !== this.__dir + || prevNode.__id !== this.__id + ) { return true; } @@ -148,8 +175,7 @@ export class ListNode extends ElementNode { static importJSON(serializedNode: SerializedListNode): ListNode { const node = $createListNode(serializedNode.listType, serializedNode.start); - node.setFormat(serializedNode.format); - node.setIndent(serializedNode.indent); + node.setId(serializedNode.id); node.setDirection(serializedNode.direction); return node; } @@ -177,6 +203,7 @@ export class ListNode extends ElementNode { tag: this.getTag(), type: 'list', version: 1, + id: this.__id, }; } @@ -277,28 +304,21 @@ function $setListThemeClassNames( } /* - * This function normalizes the children of a ListNode after the conversion from HTML, - * ensuring that they are all ListItemNodes and contain either a single nested ListNode - * or some other inline content. + * This function is a custom normalization function to allow nested lists within list item elements. + * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303 + * With modifications made. */ function $normalizeChildren(nodes: Array): Array { const normalizedListItems: Array = []; - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; + + for (const node of nodes) { if ($isListItemNode(node)) { normalizedListItems.push(node); - const children = node.getChildren(); - if (children.length > 1) { - children.forEach((child) => { - if ($isListNode(child)) { - normalizedListItems.push($wrapInListItem(child)); - } - }); - } } else { normalizedListItems.push($wrapInListItem(node)); } } + return normalizedListItems; } @@ -334,6 +354,14 @@ function $convertListNode(domNode: HTMLElement): DOMConversionOutput { } } + if (domNode.id && node) { + node.setId(domNode.id); + } + + if (domNode.dir && node) { + node.setDirection(extractDirectionFromElement(domNode)); + } + return { after: $normalizeChildren, node, diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 581db0294f5..523c7eb126e 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -1265,99 +1265,5 @@ describe('LexicalListItemNode tests', () => { expect($isListItemNode(listItemNode)).toBe(true); }); }); - - describe('ListItemNode.setIndent()', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - - beforeEach(async () => { - const {editor} = testEnv; - - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); - - listItemNode2 = new ListItemNode(); - - root.append(listNode); - listNode.append(listItemNode1, listItemNode2); - listItemNode1.append(new TextNode('one')); - listItemNode2.append(new TextNode('two')); - }); - }); - it('indents and outdents list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode1.setIndent(3); - }); - - await editor.update(() => { - expect(listItemNode1.getIndent()).toBe(3); - }); - - expectHtmlToBeEqual( - editor.getRootElement()!.innerHTML, - html` -

      -
    • -
        -
      • -
          -
        • -
            -
          • - one -
          • -
          -
        • -
        -
      • -
      -
    • -
    • - two -
    • -
    - `, - ); - - await editor.update(() => { - listItemNode1.setIndent(0); - }); - - await editor.update(() => { - expect(listItemNode1.getIndent()).toBe(0); - }); - - expectHtmlToBeEqual( - editor.getRootElement()!.innerHTML, - html` -
      -
    • - one -
    • -
    • - two -
    • -
    - `, - ); - }); - - it('handles fractional indent values', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode1.setIndent(0.5); - }); - - await editor.update(() => { - expect(listItemNode1.getIndent()).toBe(0); - }); - }); - }); }); }); diff --git a/resources/js/wysiwyg/nodes/custom-list-item.ts b/resources/js/wysiwyg/nodes/custom-list-item.ts deleted file mode 100644 index 11887b4364d..00000000000 --- a/resources/js/wysiwyg/nodes/custom-list-item.ts +++ /dev/null @@ -1,120 +0,0 @@ -import {$isListNode, ListItemNode, SerializedListItemNode} from "@lexical/list"; -import {EditorConfig} from "lexical/LexicalEditor"; -import {DOMExportOutput, LexicalEditor, LexicalNode} from "lexical"; - -import {el} from "../utils/dom"; -import {$isCustomListNode} from "./custom-list"; - -function updateListItemChecked( - dom: HTMLElement, - listItemNode: ListItemNode, -): void { - // Only set task list attrs for leaf list items - const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild()); - dom.classList.toggle('task-list-item', shouldBeTaskItem); - if (listItemNode.__checked) { - dom.setAttribute('checked', 'checked'); - } else { - dom.removeAttribute('checked'); - } -} - - -export class CustomListItemNode extends ListItemNode { - static getType(): string { - return 'custom-list-item'; - } - - static clone(node: CustomListItemNode): CustomListItemNode { - return new CustomListItemNode(node.__value, node.__checked, node.__key); - } - - createDOM(config: EditorConfig): HTMLElement { - const element = document.createElement('li'); - const parent = this.getParent(); - - if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(element, this); - } - - element.value = this.__value; - - if ($hasNestedListWithoutLabel(this)) { - element.style.listStyle = 'none'; - } - - return element; - } - - updateDOM( - prevNode: ListItemNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { - const parent = this.getParent(); - if ($isListNode(parent) && parent.getListType() === 'check') { - updateListItemChecked(dom, this); - } - - dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : ''; - // @ts-expect-error - this is always HTMLListItemElement - dom.value = this.__value; - - return false; - } - - exportDOM(editor: LexicalEditor): DOMExportOutput { - const element = this.createDOM(editor._config); - element.style.textAlign = this.getFormatType(); - - if (element.classList.contains('task-list-item')) { - const input = el('input', { - type: 'checkbox', - disabled: 'disabled', - }); - if (element.hasAttribute('checked')) { - input.setAttribute('checked', 'checked'); - element.removeAttribute('checked'); - } - - element.prepend(input); - } - - return { - element, - }; - } - - exportJSON(): SerializedListItemNode { - return { - ...super.exportJSON(), - type: 'custom-list-item', - }; - } -} - -function $hasNestedListWithoutLabel(node: CustomListItemNode): boolean { - const children = node.getChildren(); - let hasLabel = false; - let hasNestedList = false; - - for (const child of children) { - if ($isCustomListNode(child)) { - hasNestedList = true; - } else if (child.getTextContent().trim().length > 0) { - hasLabel = true; - } - } - - return hasNestedList && !hasLabel; -} - -export function $isCustomListItemNode( - node: LexicalNode | null | undefined, -): node is CustomListItemNode { - return node instanceof CustomListItemNode; -} - -export function $createCustomListItemNode(): CustomListItemNode { - return new CustomListItemNode(); -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-list.ts b/resources/js/wysiwyg/nodes/custom-list.ts deleted file mode 100644 index 4b05fa62e25..00000000000 --- a/resources/js/wysiwyg/nodes/custom-list.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { - DOMConversionFn, - DOMConversionMap, EditorConfig, - LexicalNode, - Spread -} from "lexical"; -import {$isListItemNode, ListItemNode, ListNode, ListType, SerializedListNode} from "@lexical/list"; -import {$createCustomListItemNode} from "./custom-list-item"; -import {extractDirectionFromElement} from "./_common"; - - -export type SerializedCustomListNode = Spread<{ - id: string; -}, SerializedListNode> - -export class CustomListNode extends ListNode { - __id: string = ''; - - static getType() { - return 'custom-list'; - } - - setId(id: string) { - const self = this.getWritable(); - self.__id = id; - } - - getId(): string { - const self = this.getLatest(); - return self.__id; - } - - static clone(node: CustomListNode) { - const newNode = new CustomListNode(node.__listType, node.__start, node.__key); - newNode.__id = node.__id; - newNode.__dir = node.__dir; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - if (this.__id) { - dom.setAttribute('id', this.__id); - } - - if (this.__dir) { - dom.setAttribute('dir', this.__dir); - } - - return dom; - } - - updateDOM(prevNode: ListNode, dom: HTMLElement, config: EditorConfig): boolean { - return super.updateDOM(prevNode, dom, config) || - prevNode.__dir !== this.__dir; - } - - exportJSON(): SerializedCustomListNode { - return { - ...super.exportJSON(), - type: 'custom-list', - version: 1, - id: this.__id, - }; - } - - static importJSON(serializedNode: SerializedCustomListNode): CustomListNode { - const node = $createCustomListNode(serializedNode.listType); - node.setId(serializedNode.id); - node.setDirection(serializedNode.direction); - return node; - } - - static importDOM(): DOMConversionMap | null { - // @ts-ignore - const converter = super.importDOM().ol().conversion as DOMConversionFn; - const customConvertFunction = (element: HTMLElement) => { - const baseResult = converter(element); - if (element.id && baseResult?.node) { - (baseResult.node as CustomListNode).setId(element.id); - } - - if (element.dir && baseResult?.node) { - (baseResult.node as CustomListNode).setDirection(extractDirectionFromElement(element)); - } - - if (baseResult) { - baseResult.after = $normalizeChildren; - } - - return baseResult; - }; - - return { - ol: () => ({ - conversion: customConvertFunction, - priority: 0, - }), - ul: () => ({ - conversion: customConvertFunction, - priority: 0, - }), - }; - } -} - -/* - * This function is a custom normalization function to allow nested lists within list item elements. - * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303 - * With modifications made. - * Copyright (c) Meta Platforms, Inc. and affiliates. - * MIT license - */ -function $normalizeChildren(nodes: Array): Array { - const normalizedListItems: Array = []; - - for (const node of nodes) { - if ($isListItemNode(node)) { - normalizedListItems.push(node); - } else { - normalizedListItems.push($wrapInListItem(node)); - } - } - - return normalizedListItems; -} - -function $wrapInListItem(node: LexicalNode): ListItemNode { - const listItemWrapper = $createCustomListItemNode(); - return listItemWrapper.append(node); -} - -export function $createCustomListNode(type: ListType): CustomListNode { - return new CustomListNode(type, 1); -} - -export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode { - return node instanceof CustomListNode; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 7b274eba13c..7e0ce9daf2b 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -17,10 +17,8 @@ import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; import {EditorUiContext} from "../ui/framework/core"; import {MediaNode} from "./media"; -import {CustomListItemNode} from "./custom-list-item"; import {CustomTableCellNode} from "./custom-table-cell"; import {CustomTableRowNode} from "./custom-table-row"; -import {CustomListNode} from "./custom-list"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; @@ -32,8 +30,8 @@ export function getNodesForPageEditor(): (KlassConstructor | CalloutNode, HeadingNode, QuoteNode, - CustomListNode, - CustomListItemNode, // TODO - Alignment? + ListNode, + ListItemNode, CustomTableNode, CustomTableRowNode, CustomTableCellNode, @@ -45,18 +43,6 @@ export function getNodesForPageEditor(): (KlassConstructor | MediaNode, // TODO - Alignment ParagraphNode, LinkNode, - { - replace: ListNode, - with: (node: ListNode) => { - return new CustomListNode(node.getListType(), node.getStart()); - } - }, - { - replace: ListItemNode, - with: (node: ListItemNode) => { - return new CustomListItemNode(node.__value, node.__checked); - } - }, { replace: TableNode, with(node: TableNode) { diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 3f0b0c495e0..5f7f41ef02c 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -14,8 +14,8 @@ import {$isImageNode} from "../nodes/image"; import {$isMediaNode} from "../nodes/media"; import {getLastSelection} from "../utils/selection"; import {$getNearestNodeBlockParent} from "../utils/nodes"; -import {$isCustomListItemNode} from "../nodes/custom-list-item"; import {$setInsetForSelection} from "../utils/lists"; +import {$isListItemNode} from "@lexical/list"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -62,7 +62,7 @@ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boo const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); const nodes = selection?.getNodes() || []; - if (nodes.length > 1 || (nodes.length === 1 && $isCustomListItemNode(nodes[0].getParent()))) { + if (nodes.length > 1 || (nodes.length === 1 && $isListItemNode(nodes[0].getParent()))) { editor.update(() => { $setInsetForSelection(editor, change); }); diff --git a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts index da8c0eae3dd..62a784d83ed 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/task-list-handler.ts @@ -1,5 +1,5 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; -import {$isCustomListItemNode} from "../../../nodes/custom-list-item"; +import {$isListItemNode} from "@lexical/list"; class TaskListHandler { protected editorContainer: HTMLElement; @@ -38,7 +38,7 @@ class TaskListHandler { this.editor.update(() => { const node = $getNearestNodeFromDOMNode(listItem); - if ($isCustomListItemNode(node)) { + if ($isListItemNode(node)) { node.setChecked(!node.getChecked()); } }); diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index d724730e3a2..1be802ebf1c 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -16,8 +16,7 @@ import { } from "./selection"; import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; -import {insertList, ListNode, ListType, removeList} from "@lexical/list"; -import {$isCustomListNode} from "../nodes/custom-list"; +import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; import {$createQuoteNode, $isQuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; @@ -51,7 +50,7 @@ export function toggleSelectionAsList(editor: LexicalEditor, type: ListType) { editor.getEditorState().read(() => { const selection = $getSelection(); const listSelected = $selectionContainsNodeType(selection, (node: LexicalNode | null | undefined): boolean => { - return $isCustomListNode(node) && (node as ListNode).getListType() === type; + return $isListNode(node) && (node as ListNode).getListType() === type; }); if (listSelected) { diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 30a97cbc1f9..646f341c2ba 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,22 +1,21 @@ -import {$createCustomListItemNode, $isCustomListItemNode, CustomListItemNode} from "../nodes/custom-list-item"; -import {$createCustomListNode, $isCustomListNode} from "../nodes/custom-list"; import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {nodeHasInset} from "./nodes"; +import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; -export function $nestListItem(node: CustomListItemNode): CustomListItemNode { +export function $nestListItem(node: ListItemNode): ListItemNode { const list = node.getParent(); - if (!$isCustomListNode(list)) { + if (!$isListNode(list)) { return node; } - const listItems = list.getChildren() as CustomListItemNode[]; + const listItems = list.getChildren() as ListItemNode[]; const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); const isFirst = nodeIndex === 0; - const newListItem = $createCustomListItemNode(); - const newList = $createCustomListNode(list.getListType()); + const newListItem = $createListItemNode(); + const newList = $createListNode(list.getListType()); newList.append(newListItem); newListItem.append(...node.getChildren()); @@ -31,11 +30,11 @@ export function $nestListItem(node: CustomListItemNode): CustomListItemNode { return newListItem; } -export function $unnestListItem(node: CustomListItemNode): CustomListItemNode { +export function $unnestListItem(node: ListItemNode): ListItemNode { const list = node.getParent(); const parentListItem = list?.getParent(); const outerList = parentListItem?.getParent(); - if (!$isCustomListNode(list) || !$isCustomListNode(outerList) || !$isCustomListItemNode(parentListItem)) { + if (!$isListNode(list) || !$isListNode(outerList) || !$isListItemNode(parentListItem)) { return node; } @@ -51,19 +50,19 @@ export function $unnestListItem(node: CustomListItemNode): CustomListItemNode { return node; } -function getListItemsForSelection(selection: BaseSelection|null): (CustomListItemNode|null)[] { +function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] { const nodes = selection?.getNodes() || []; const listItemNodes = []; outer: for (const node of nodes) { - if ($isCustomListItemNode(node)) { + if ($isListItemNode(node)) { listItemNodes.push(node); continue; } const parents = node.getParents(); for (const parent of parents) { - if ($isCustomListItemNode(parent)) { + if ($isListItemNode(parent)) { listItemNodes.push(parent); continue outer; } @@ -75,8 +74,8 @@ function getListItemsForSelection(selection: BaseSelection|null): (CustomListIte return listItemNodes; } -function $reduceDedupeListItems(listItems: (CustomListItemNode|null)[]): CustomListItemNode[] { - const listItemMap: Record = {}; +function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[] { + const listItemMap: Record = {}; for (const item of listItems) { if (item === null) { From 57d8449660e2cf5acd9bfaa6c951b3dfd040bf42 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 3 Dec 2024 20:08:33 +0000 Subject: [PATCH 60/72] Lexical: Merged custom table node code --- .../lexical/table/LexicalTableCellNode.ts | 134 ++++++---- .../wysiwyg/lexical/table/LexicalTableNode.ts | 98 ++++++- .../lexical/table/LexicalTableRowNode.ts | 57 ++-- .../table/LexicalTableSelectionHelpers.ts | 53 ---- .../unit/LexicalTableRowNode.test.ts | 5 +- .../js/wysiwyg/nodes/custom-table-cell.ts | 247 ------------------ .../js/wysiwyg/nodes/custom-table-row.ts | 106 -------- resources/js/wysiwyg/nodes/custom-table.ts | 166 ------------ resources/js/wysiwyg/nodes/index.ts | 33 +-- .../js/wysiwyg/ui/defaults/buttons/tables.ts | 35 ++- .../js/wysiwyg/ui/defaults/forms/tables.ts | 21 +- .../ui/framework/blocks/table-creator.ts | 3 +- .../ui/framework/helpers/table-resizer.ts | 5 +- .../helpers/table-selection-handler.ts | 8 +- .../js/wysiwyg/utils/table-copy-paste.ts | 42 +-- resources/js/wysiwyg/utils/table-map.ts | 28 +- resources/js/wysiwyg/utils/tables.ts | 70 ++--- 17 files changed, 323 insertions(+), 788 deletions(-) delete mode 100644 resources/js/wysiwyg/nodes/custom-table-cell.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-table-row.ts delete mode 100644 resources/js/wysiwyg/nodes/custom-table.ts diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 455d39bf6c9..72676b9bacb 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -28,7 +28,8 @@ import { ElementNode, } from 'lexical'; -import {COLUMN_WIDTH, PIXEL_VALUE_REG_EXP} from './constants'; +import {extractStyleMapFromElement, StyleMap} from "../../utils/dom"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "../../nodes/_common"; export const TableCellHeaderStates = { BOTH: 3, @@ -47,6 +48,8 @@ export type SerializedTableCellNode = Spread< headerState: TableCellHeaderState; width?: number; backgroundColor?: null | string; + styles: Record; + alignment: CommonBlockAlignment; }, SerializedElementNode >; @@ -63,6 +66,10 @@ export class TableCellNode extends ElementNode { __width?: number; /** @internal */ __backgroundColor: null | string; + /** @internal */ + __styles: StyleMap = new Map; + /** @internal */ + __alignment: CommonBlockAlignment = ''; static getType(): string { return 'tablecell'; @@ -77,6 +84,8 @@ export class TableCellNode extends ElementNode { ); cellNode.__rowSpan = node.__rowSpan; cellNode.__backgroundColor = node.__backgroundColor; + cellNode.__styles = new Map(node.__styles); + cellNode.__alignment = node.__alignment; return cellNode; } @@ -94,16 +103,20 @@ export class TableCellNode extends ElementNode { } static importJSON(serializedNode: SerializedTableCellNode): TableCellNode { - const colSpan = serializedNode.colSpan || 1; - const rowSpan = serializedNode.rowSpan || 1; - const cellNode = $createTableCellNode( - serializedNode.headerState, - colSpan, - serializedNode.width || undefined, + const node = $createTableCellNode( + serializedNode.headerState, + serializedNode.colSpan, + serializedNode.width, ); - cellNode.__rowSpan = rowSpan; - cellNode.__backgroundColor = serializedNode.backgroundColor || null; - return cellNode; + + if (serializedNode.rowSpan) { + node.setRowSpan(serializedNode.rowSpan); + } + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + node.setAlignment(serializedNode.alignment); + + return node; } constructor( @@ -144,34 +157,19 @@ export class TableCellNode extends ElementNode { this.hasHeader() && config.theme.tableCellHeader, ); + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + return element; } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); - - if (element) { - const element_ = element as HTMLTableCellElement; - element_.style.border = '1px solid black'; - if (this.__colSpan > 1) { - element_.colSpan = this.__colSpan; - } - if (this.__rowSpan > 1) { - element_.rowSpan = this.__rowSpan; - } - element_.style.width = `${this.getWidth() || COLUMN_WIDTH}px`; - - element_.style.verticalAlign = 'top'; - element_.style.textAlign = 'start'; - - const backgroundColor = this.getBackgroundColor(); - if (backgroundColor !== null) { - element_.style.backgroundColor = backgroundColor; - } else if (this.hasHeader()) { - element_.style.backgroundColor = '#f2f3f5'; - } - } - return { element, }; @@ -186,6 +184,8 @@ export class TableCellNode extends ElementNode { rowSpan: this.__rowSpan, type: 'tablecell', width: this.getWidth(), + styles: Object.fromEntries(this.__styles), + alignment: this.__alignment, }; } @@ -231,6 +231,38 @@ export class TableCellNode extends ElementNode { return this.getLatest().__width; } + clearWidth(): void { + const self = this.getWritable(); + self.__width = undefined; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + + updateTag(tag: string): void { + const isHeader = tag.toLowerCase() === 'th'; + const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS; + const self = this.getWritable(); + self.__headerState = state; + } + getBackgroundColor(): null | string { return this.getLatest().__backgroundColor; } @@ -265,7 +297,9 @@ export class TableCellNode extends ElementNode { prevNode.__width !== this.__width || prevNode.__colSpan !== this.__colSpan || prevNode.__rowSpan !== this.__rowSpan || - prevNode.__backgroundColor !== this.__backgroundColor + prevNode.__backgroundColor !== this.__backgroundColor || + prevNode.__styles !== this.__styles || + prevNode.__alignment !== this.__alignment ); } @@ -287,38 +321,42 @@ export class TableCellNode extends ElementNode { } export function $convertTableCellNodeElement( - domNode: Node, + domNode: Node, ): DOMConversionOutput { const domNode_ = domNode as HTMLTableCellElement; const nodeName = domNode.nodeName.toLowerCase(); let width: number | undefined = undefined; + + const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/; if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) { width = parseFloat(domNode_.style.width); } const tableCellNode = $createTableCellNode( - nodeName === 'th' - ? TableCellHeaderStates.ROW - : TableCellHeaderStates.NO_STATUS, - domNode_.colSpan, - width, + nodeName === 'th' + ? TableCellHeaderStates.ROW + : TableCellHeaderStates.NO_STATUS, + domNode_.colSpan, + width, ); tableCellNode.__rowSpan = domNode_.rowSpan; - const backgroundColor = domNode_.style.backgroundColor; - if (backgroundColor !== '') { - tableCellNode.__backgroundColor = backgroundColor; - } const style = domNode_.style; const textDecoration = style.textDecoration.split(' '); const hasBoldFontWeight = - style.fontWeight === '700' || style.fontWeight === 'bold'; + style.fontWeight === '700' || style.fontWeight === 'bold'; const hasLinethroughTextDecoration = textDecoration.includes('line-through'); const hasItalicFontStyle = style.fontStyle === 'italic'; const hasUnderlineTextDecoration = textDecoration.includes('underline'); + + if (domNode instanceof HTMLElement) { + tableCellNode.setStyles(extractStyleMapFromElement(domNode)); + tableCellNode.setAlignment(extractAlignmentFromElement(domNode)); + } + return { after: (childLexicalNodes) => { if (childLexicalNodes.length === 0) { @@ -330,8 +368,8 @@ export function $convertTableCellNodeElement( if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) { const paragraphNode = $createParagraphNode(); if ( - $isLineBreakNode(lexicalNode) && - lexicalNode.getTextContent() === '\n' + $isLineBreakNode(lexicalNode) && + lexicalNode.getTextContent() === '\n' ) { return null; } @@ -360,7 +398,7 @@ export function $convertTableCellNodeElement( } export function $createTableCellNode( - headerState: TableCellHeaderState, + headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, colSpan = 1, width?: number, ): TableCellNode { diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index 357ba3e738b..ab163005370 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -7,7 +7,7 @@ */ import type {TableCellNode} from './LexicalTableCellNode'; -import type { +import { DOMConversionMap, DOMConversionOutput, DOMExportOutput, @@ -15,7 +15,7 @@ import type { LexicalEditor, LexicalNode, NodeKey, - SerializedElementNode, + SerializedElementNode, Spread, } from 'lexical'; import {addClassNamesToElement, isHTMLElement} from '@lexical/utils'; @@ -27,19 +27,36 @@ import { import {$isTableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; -import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; import {getTable} from './LexicalTableSelectionHelpers'; - -export type SerializedTableNode = SerializedElementNode; +import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import { + commonPropertiesDifferent, deserializeCommonBlockNode, + SerializedCommonBlockNode, setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "../../nodes/_common"; +import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom"; +import {getTableColumnWidths} from "../../utils/tables"; + +export type SerializedTableNode = Spread<{ + colWidths: string[]; + styles: Record, +}, SerializedCommonBlockNode> /** @noInheritDoc */ -export class TableNode extends ElementNode { +export class TableNode extends CommonBlockNode { + __colWidths: string[] = []; + __styles: StyleMap = new Map; + static getType(): string { return 'table'; } static clone(node: TableNode): TableNode { - return new TableNode(node.__key); + const newNode = new TableNode(node.__key); + copyCommonBlockProperties(node, newNode); + newNode.__colWidths = node.__colWidths; + newNode.__styles = new Map(node.__styles); + return newNode; } static importDOM(): DOMConversionMap | null { @@ -52,18 +69,24 @@ export class TableNode extends ElementNode { } static importJSON(_serializedNode: SerializedTableNode): TableNode { - return $createTableNode(); + const node = $createTableNode(); + deserializeCommonBlockNode(_serializedNode, node); + node.setColWidths(_serializedNode.colWidths); + node.setStyles(new Map(Object.entries(_serializedNode.styles))); + return node; } constructor(key?: NodeKey) { super(key); } - exportJSON(): SerializedElementNode { + exportJSON(): SerializedTableNode { return { ...super.exportJSON(), type: 'table', version: 1, + colWidths: this.__colWidths, + styles: Object.fromEntries(this.__styles), }; } @@ -72,11 +95,33 @@ export class TableNode extends ElementNode { addClassNamesToElement(tableElement, config.theme.table); + updateElementWithCommonBlockProps(tableElement, this); + + const colWidths = this.getColWidths(); + if (colWidths.length > 0) { + const colgroup = el('colgroup'); + for (const width of colWidths) { + const col = el('col'); + if (width) { + col.style.width = width; + } + colgroup.append(col); + } + tableElement.append(colgroup); + } + + for (const [name, value] of this.__styles.entries()) { + tableElement.style.setProperty(name, value); + } + return tableElement; } - updateDOM(): boolean { - return false; + updateDOM(_prevNode: TableNode): boolean { + return commonPropertiesDifferent(_prevNode, this) + || this.__colWidths.join(':') !== _prevNode.__colWidths.join(':') + || this.__styles.size !== _prevNode.__styles.size + || (Array.from(this.__styles.values()).join(':') !== (Array.from(_prevNode.__styles.values()).join(':'))); } exportDOM(editor: LexicalEditor): DOMExportOutput { @@ -115,6 +160,26 @@ export class TableNode extends ElementNode { return true; } + setColWidths(widths: string[]) { + const self = this.getWritable(); + self.__colWidths = widths; + } + + getColWidths(): string[] { + const self = this.getLatest(); + return self.__colWidths; + } + + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + getCordsFromCellNode( tableCellNode: TableCellNode, table: TableDOMTable, @@ -239,8 +304,15 @@ export function $getElementForTableNode( return getTable(tableElement); } -export function $convertTableElement(_domNode: Node): DOMConversionOutput { - return {node: $createTableNode()}; +export function $convertTableElement(element: HTMLElement): DOMConversionOutput { + const node = $createTableNode(); + setCommonBlockPropsFromElement(element, node); + + const colWidths = getTableColumnWidths(element as HTMLTableElement); + node.setColWidths(colWidths); + node.setStyles(extractStyleMapFromElement(element)); + + return {node}; } export function $createTableNode(): TableNode { diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts index eddea69a27e..07db2b65dc0 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableRowNode.ts @@ -20,11 +20,12 @@ import { SerializedElementNode, } from 'lexical'; -import {PIXEL_VALUE_REG_EXP} from './constants'; +import {extractStyleMapFromElement, sizeToPixels, StyleMap} from "../../utils/dom"; export type SerializedTableRowNode = Spread< { - height?: number; + styles: Record, + height?: number, }, SerializedElementNode >; @@ -33,13 +34,17 @@ export type SerializedTableRowNode = Spread< export class TableRowNode extends ElementNode { /** @internal */ __height?: number; + /** @internal */ + __styles: StyleMap = new Map(); static getType(): string { return 'tablerow'; } static clone(node: TableRowNode): TableRowNode { - return new TableRowNode(node.__height, node.__key); + const newNode = new TableRowNode(node.__key); + newNode.__styles = new Map(node.__styles); + return newNode; } static importDOM(): DOMConversionMap | null { @@ -52,20 +57,24 @@ export class TableRowNode extends ElementNode { } static importJSON(serializedNode: SerializedTableRowNode): TableRowNode { - return $createTableRowNode(serializedNode.height); + const node = $createTableRowNode(); + + node.setStyles(new Map(Object.entries(serializedNode.styles))); + + return node; } - constructor(height?: number, key?: NodeKey) { + constructor(key?: NodeKey) { super(key); - this.__height = height; } exportJSON(): SerializedTableRowNode { return { ...super.exportJSON(), - ...(this.getHeight() && {height: this.getHeight()}), type: 'tablerow', version: 1, + styles: Object.fromEntries(this.__styles), + height: this.__height || 0, }; } @@ -76,6 +85,10 @@ export class TableRowNode extends ElementNode { element.style.height = `${this.__height}px`; } + for (const [name, value] of this.__styles.entries()) { + element.style.setProperty(name, value); + } + addClassNamesToElement(element, config.theme.tableRow); return element; @@ -85,6 +98,16 @@ export class TableRowNode extends ElementNode { return true; } + getStyles(): StyleMap { + const self = this.getLatest(); + return new Map(self.__styles); + } + + setStyles(styles: StyleMap): void { + const self = this.getWritable(); + self.__styles = new Map(styles); + } + setHeight(height: number): number | null | undefined { const self = this.getWritable(); self.__height = height; @@ -96,7 +119,8 @@ export class TableRowNode extends ElementNode { } updateDOM(prevNode: TableRowNode): boolean { - return prevNode.__height !== this.__height; + return prevNode.__height !== this.__height + || prevNode.__styles !== this.__styles; } canBeEmpty(): false { @@ -109,18 +133,21 @@ export class TableRowNode extends ElementNode { } export function $convertTableRowElement(domNode: Node): DOMConversionOutput { - const domNode_ = domNode as HTMLTableCellElement; - let height: number | undefined = undefined; + const rowNode = $createTableRowNode(); + const domNode_ = domNode as HTMLElement; + + const height = sizeToPixels(domNode_.style.height); + rowNode.setHeight(height); - if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) { - height = parseFloat(domNode_.style.height); + if (domNode instanceof HTMLElement) { + rowNode.setStyles(extractStyleMapFromElement(domNode)); } - return {node: $createTableRowNode(height)}; + return {node: rowNode}; } -export function $createTableRowNode(height?: number): TableRowNode { - return $applyNodeReplacement(new TableRowNode(height)); +export function $createTableRowNode(): TableRowNode { + return $applyNodeReplacement(new TableRowNode()); } export function $isTableRowNode( diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 812cccc0d25..6c3317c5dfa 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -438,59 +438,6 @@ export function applyTableHandlers( ), ); - tableObserver.listenersToRemove.add( - editor.registerCommand( - FORMAT_ELEMENT_COMMAND, - (formatType) => { - const selection = $getSelection(); - if ( - !$isTableSelection(selection) || - !$isSelectionInTable(selection, tableNode) - ) { - return false; - } - - const anchorNode = selection.anchor.getNode(); - const focusNode = selection.focus.getNode(); - if (!$isTableCellNode(anchorNode) || !$isTableCellNode(focusNode)) { - return false; - } - - const [tableMap, anchorCell, focusCell] = $computeTableMap( - tableNode, - anchorNode, - focusNode, - ); - const maxRow = Math.max(anchorCell.startRow, focusCell.startRow); - const maxColumn = Math.max( - anchorCell.startColumn, - focusCell.startColumn, - ); - const minRow = Math.min(anchorCell.startRow, focusCell.startRow); - const minColumn = Math.min( - anchorCell.startColumn, - focusCell.startColumn, - ); - for (let i = minRow; i <= maxRow; i++) { - for (let j = minColumn; j <= maxColumn; j++) { - const cell = tableMap[i][j].cell; - cell.setFormat(formatType); - - const cellChildren = cell.getChildren(); - for (let k = 0; k < cellChildren.length; k++) { - const child = cellChildren[k]; - if ($isElementNode(child) && !child.isInline()) { - child.setFormat(formatType); - } - } - } - } - return true; - }, - COMMAND_PRIORITY_CRITICAL, - ), - ); - tableObserver.listenersToRemove.add( editor.registerCommand( CONTROLLED_TEXT_INSERTION_COMMAND, diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts index 285d587bf5f..5dbf03d9e9c 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableRowNode.test.ts @@ -39,10 +39,9 @@ describe('LexicalTableRowNode tests', () => { ``, ); - const rowHeight = 36; - const rowWithCustomHeightNode = $createTableRowNode(36); + const rowWithCustomHeightNode = $createTableRowNode(); expect(rowWithCustomHeightNode.createDOM(editorConfig).outerHTML).toBe( - ``, + ``, ); }); }); diff --git a/resources/js/wysiwyg/nodes/custom-table-cell.ts b/resources/js/wysiwyg/nodes/custom-table-cell.ts deleted file mode 100644 index 793302cfec4..00000000000 --- a/resources/js/wysiwyg/nodes/custom-table-cell.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { - $createParagraphNode, - $isElementNode, - $isLineBreakNode, - $isTextNode, - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - LexicalEditor, - LexicalNode, - Spread -} from "lexical"; - -import { - $createTableCellNode, - $isTableCellNode, - SerializedTableCellNode, - TableCellHeaderStates, - TableCellNode -} from "@lexical/table"; -import {TableCellHeaderState} from "@lexical/table/LexicalTableCellNode"; -import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; -import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; - -export type SerializedCustomTableCellNode = Spread<{ - styles: Record; - alignment: CommonBlockAlignment; -}, SerializedTableCellNode> - -export class CustomTableCellNode extends TableCellNode { - __styles: StyleMap = new Map; - __alignment: CommonBlockAlignment = ''; - - static getType(): string { - return 'custom-table-cell'; - } - - static clone(node: CustomTableCellNode): CustomTableCellNode { - const cellNode = new CustomTableCellNode( - node.__headerState, - node.__colSpan, - node.__width, - node.__key, - ); - cellNode.__rowSpan = node.__rowSpan; - cellNode.__styles = new Map(node.__styles); - cellNode.__alignment = node.__alignment; - return cellNode; - } - - clearWidth(): void { - const self = this.getWritable(); - self.__width = undefined; - } - - getStyles(): StyleMap { - const self = this.getLatest(); - return new Map(self.__styles); - } - - setStyles(styles: StyleMap): void { - const self = this.getWritable(); - self.__styles = new Map(styles); - } - - setAlignment(alignment: CommonBlockAlignment) { - const self = this.getWritable(); - self.__alignment = alignment; - } - - getAlignment(): CommonBlockAlignment { - const self = this.getLatest(); - return self.__alignment; - } - - updateTag(tag: string): void { - const isHeader = tag.toLowerCase() === 'th'; - const state = isHeader ? TableCellHeaderStates.ROW : TableCellHeaderStates.NO_STATUS; - const self = this.getWritable(); - self.__headerState = state; - } - - createDOM(config: EditorConfig): HTMLElement { - const element = super.createDOM(config); - - for (const [name, value] of this.__styles.entries()) { - element.style.setProperty(name, value); - } - - if (this.__alignment) { - element.classList.add('align-' + this.__alignment); - } - - return element; - } - - updateDOM(prevNode: CustomTableCellNode): boolean { - return super.updateDOM(prevNode) - || this.__styles !== prevNode.__styles - || this.__alignment !== prevNode.__alignment; - } - - static importDOM(): DOMConversionMap | null { - return { - td: (node: Node) => ({ - conversion: $convertCustomTableCellNodeElement, - priority: 0, - }), - th: (node: Node) => ({ - conversion: $convertCustomTableCellNodeElement, - priority: 0, - }), - }; - } - - exportDOM(editor: LexicalEditor): DOMExportOutput { - const element = this.createDOM(editor._config); - return { - element - }; - } - - static importJSON(serializedNode: SerializedCustomTableCellNode): CustomTableCellNode { - const node = $createCustomTableCellNode( - serializedNode.headerState, - serializedNode.colSpan, - serializedNode.width, - ); - - node.setStyles(new Map(Object.entries(serializedNode.styles))); - node.setAlignment(serializedNode.alignment); - - return node; - } - - exportJSON(): SerializedCustomTableCellNode { - return { - ...super.exportJSON(), - type: 'custom-table-cell', - styles: Object.fromEntries(this.__styles), - alignment: this.__alignment, - }; - } -} - -function $convertCustomTableCellNodeElement(domNode: Node): DOMConversionOutput { - const output = $convertTableCellNodeElement(domNode); - - if (domNode instanceof HTMLElement && output.node instanceof CustomTableCellNode) { - output.node.setStyles(extractStyleMapFromElement(domNode)); - output.node.setAlignment(extractAlignmentFromElement(domNode)); - } - - return output; -} - -/** - * Function taken from: - * https://github.com/facebook/lexical/blob/e1881a6e409e1541c10dd0b5378f3a38c9dc8c9e/packages/lexical-table/src/LexicalTableCellNode.ts#L289 - * Copyright (c) Meta Platforms, Inc. and affiliates. - * MIT LICENSE - * Modified since copy. - */ -export function $convertTableCellNodeElement( - domNode: Node, -): DOMConversionOutput { - const domNode_ = domNode as HTMLTableCellElement; - const nodeName = domNode.nodeName.toLowerCase(); - - let width: number | undefined = undefined; - - - const PIXEL_VALUE_REG_EXP = /^(\d+(?:\.\d+)?)px$/; - if (PIXEL_VALUE_REG_EXP.test(domNode_.style.width)) { - width = parseFloat(domNode_.style.width); - } - - const tableCellNode = $createTableCellNode( - nodeName === 'th' - ? TableCellHeaderStates.ROW - : TableCellHeaderStates.NO_STATUS, - domNode_.colSpan, - width, - ); - - tableCellNode.__rowSpan = domNode_.rowSpan; - - const style = domNode_.style; - const textDecoration = style.textDecoration.split(' '); - const hasBoldFontWeight = - style.fontWeight === '700' || style.fontWeight === 'bold'; - const hasLinethroughTextDecoration = textDecoration.includes('line-through'); - const hasItalicFontStyle = style.fontStyle === 'italic'; - const hasUnderlineTextDecoration = textDecoration.includes('underline'); - return { - after: (childLexicalNodes) => { - if (childLexicalNodes.length === 0) { - childLexicalNodes.push($createParagraphNode()); - } - return childLexicalNodes; - }, - forChild: (lexicalNode, parentLexicalNode) => { - if ($isTableCellNode(parentLexicalNode) && !$isElementNode(lexicalNode)) { - const paragraphNode = $createParagraphNode(); - if ( - $isLineBreakNode(lexicalNode) && - lexicalNode.getTextContent() === '\n' - ) { - return null; - } - if ($isTextNode(lexicalNode)) { - if (hasBoldFontWeight) { - lexicalNode.toggleFormat('bold'); - } - if (hasLinethroughTextDecoration) { - lexicalNode.toggleFormat('strikethrough'); - } - if (hasItalicFontStyle) { - lexicalNode.toggleFormat('italic'); - } - if (hasUnderlineTextDecoration) { - lexicalNode.toggleFormat('underline'); - } - } - paragraphNode.append(lexicalNode); - return paragraphNode; - } - - return lexicalNode; - }, - node: tableCellNode, - }; -} - - -export function $createCustomTableCellNode( - headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, - colSpan = 1, - width?: number, -): CustomTableCellNode { - return new CustomTableCellNode(headerState, colSpan, width); -} - -export function $isCustomTableCellNode(node: LexicalNode | null | undefined): node is CustomTableCellNode { - return node instanceof CustomTableCellNode; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-table-row.ts b/resources/js/wysiwyg/nodes/custom-table-row.ts deleted file mode 100644 index f4702f36dd5..00000000000 --- a/resources/js/wysiwyg/nodes/custom-table-row.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { - DOMConversionMap, - DOMConversionOutput, - EditorConfig, - LexicalNode, - Spread -} from "lexical"; - -import { - SerializedTableRowNode, - TableRowNode -} from "@lexical/table"; -import {NodeKey} from "lexical/LexicalNode"; -import {extractStyleMapFromElement, StyleMap} from "../utils/dom"; - -export type SerializedCustomTableRowNode = Spread<{ - styles: Record, -}, SerializedTableRowNode> - -export class CustomTableRowNode extends TableRowNode { - __styles: StyleMap = new Map(); - - constructor(key?: NodeKey) { - super(0, key); - } - - static getType(): string { - return 'custom-table-row'; - } - - static clone(node: CustomTableRowNode): CustomTableRowNode { - const cellNode = new CustomTableRowNode(node.__key); - - cellNode.__styles = new Map(node.__styles); - return cellNode; - } - - getStyles(): StyleMap { - const self = this.getLatest(); - return new Map(self.__styles); - } - - setStyles(styles: StyleMap): void { - const self = this.getWritable(); - self.__styles = new Map(styles); - } - - createDOM(config: EditorConfig): HTMLElement { - const element = super.createDOM(config); - - for (const [name, value] of this.__styles.entries()) { - element.style.setProperty(name, value); - } - - return element; - } - - updateDOM(prevNode: CustomTableRowNode): boolean { - return super.updateDOM(prevNode) - || this.__styles !== prevNode.__styles; - } - - static importDOM(): DOMConversionMap | null { - return { - tr: (node: Node) => ({ - conversion: $convertTableRowElement, - priority: 0, - }), - }; - } - - static importJSON(serializedNode: SerializedCustomTableRowNode): CustomTableRowNode { - const node = $createCustomTableRowNode(); - - node.setStyles(new Map(Object.entries(serializedNode.styles))); - - return node; - } - - exportJSON(): SerializedCustomTableRowNode { - return { - ...super.exportJSON(), - height: 0, - type: 'custom-table-row', - styles: Object.fromEntries(this.__styles), - }; - } -} - -export function $convertTableRowElement(domNode: Node): DOMConversionOutput { - const rowNode = $createCustomTableRowNode(); - - if (domNode instanceof HTMLElement) { - rowNode.setStyles(extractStyleMapFromElement(domNode)); - } - - return {node: rowNode}; -} - -export function $createCustomTableRowNode(): CustomTableRowNode { - return new CustomTableRowNode(); -} - -export function $isCustomTableRowNode(node: LexicalNode | null | undefined): node is CustomTableRowNode { - return node instanceof CustomTableRowNode; -} \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/custom-table.ts b/resources/js/wysiwyg/nodes/custom-table.ts deleted file mode 100644 index c25c06c6515..00000000000 --- a/resources/js/wysiwyg/nodes/custom-table.ts +++ /dev/null @@ -1,166 +0,0 @@ -import {SerializedTableNode, TableNode} from "@lexical/table"; -import {DOMConversion, DOMConversionMap, DOMConversionOutput, LexicalNode, Spread} from "lexical"; -import {EditorConfig} from "lexical/LexicalEditor"; - -import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom"; -import {getTableColumnWidths} from "../utils/tables"; -import { - CommonBlockAlignment, deserializeCommonBlockNode, - SerializedCommonBlockNode, - setCommonBlockPropsFromElement, - updateElementWithCommonBlockProps -} from "./_common"; - -export type SerializedCustomTableNode = Spread, -}, SerializedTableNode>, SerializedCommonBlockNode> - -export class CustomTableNode extends TableNode { - __id: string = ''; - __colWidths: string[] = []; - __styles: StyleMap = new Map; - __alignment: CommonBlockAlignment = ''; - __inset: number = 0; - - static getType() { - return 'custom-table'; - } - - setId(id: string) { - const self = this.getWritable(); - self.__id = id; - } - - getId(): string { - const self = this.getLatest(); - return self.__id; - } - - setAlignment(alignment: CommonBlockAlignment) { - const self = this.getWritable(); - self.__alignment = alignment; - } - - getAlignment(): CommonBlockAlignment { - const self = this.getLatest(); - return self.__alignment; - } - - setInset(size: number) { - const self = this.getWritable(); - self.__inset = size; - } - - getInset(): number { - const self = this.getLatest(); - return self.__inset; - } - - setColWidths(widths: string[]) { - const self = this.getWritable(); - self.__colWidths = widths; - } - - getColWidths(): string[] { - const self = this.getLatest(); - return self.__colWidths; - } - - getStyles(): StyleMap { - const self = this.getLatest(); - return new Map(self.__styles); - } - - setStyles(styles: StyleMap): void { - const self = this.getWritable(); - self.__styles = new Map(styles); - } - - static clone(node: CustomTableNode) { - const newNode = new CustomTableNode(node.__key); - newNode.__id = node.__id; - newNode.__colWidths = node.__colWidths; - newNode.__styles = new Map(node.__styles); - newNode.__alignment = node.__alignment; - newNode.__inset = node.__inset; - return newNode; - } - - createDOM(config: EditorConfig): HTMLElement { - const dom = super.createDOM(config); - updateElementWithCommonBlockProps(dom, this); - - const colWidths = this.getColWidths(); - if (colWidths.length > 0) { - const colgroup = el('colgroup'); - for (const width of colWidths) { - const col = el('col'); - if (width) { - col.style.width = width; - } - colgroup.append(col); - } - dom.append(colgroup); - } - - for (const [name, value] of this.__styles.entries()) { - dom.style.setProperty(name, value); - } - - return dom; - } - - updateDOM(): boolean { - return true; - } - - exportJSON(): SerializedCustomTableNode { - return { - ...super.exportJSON(), - type: 'custom-table', - version: 1, - id: this.__id, - colWidths: this.__colWidths, - styles: Object.fromEntries(this.__styles), - alignment: this.__alignment, - inset: this.__inset, - }; - } - - static importJSON(serializedNode: SerializedCustomTableNode): CustomTableNode { - const node = $createCustomTableNode(); - deserializeCommonBlockNode(serializedNode, node); - node.setColWidths(serializedNode.colWidths); - node.setStyles(new Map(Object.entries(serializedNode.styles))); - return node; - } - - static importDOM(): DOMConversionMap|null { - return { - table(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - const node = $createCustomTableNode(); - setCommonBlockPropsFromElement(element, node); - - const colWidths = getTableColumnWidths(element as HTMLTableElement); - node.setColWidths(colWidths); - node.setStyles(extractStyleMapFromElement(element)); - - return {node}; - }, - priority: 1, - }; - }, - }; - } -} - -export function $createCustomTableNode(): CustomTableNode { - return new CustomTableNode(); -} - -export function $isCustomTableNode(node: LexicalNode | null | undefined): node is CustomTableNode { - return node instanceof CustomTableNode; -} diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index 7e0ce9daf2b..03213e2629a 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -11,14 +11,11 @@ import {ImageNode} from "./image"; import {DetailsNode, SummaryNode} from "./details"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; -import {CustomTableNode} from "./custom-table"; import {HorizontalRuleNode} from "./horizontal-rule"; import {CodeBlockNode} from "./code-block"; import {DiagramNode} from "./diagram"; import {EditorUiContext} from "../ui/framework/core"; import {MediaNode} from "./media"; -import {CustomTableCellNode} from "./custom-table-cell"; -import {CustomTableRowNode} from "./custom-table-row"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; @@ -32,9 +29,9 @@ export function getNodesForPageEditor(): (KlassConstructor | QuoteNode, ListNode, ListItemNode, - CustomTableNode, - CustomTableRowNode, - CustomTableCellNode, + TableNode, + TableRowNode, + TableCellNode, ImageNode, // TODO - Alignment HorizontalRuleNode, DetailsNode, SummaryNode, @@ -43,30 +40,6 @@ export function getNodesForPageEditor(): (KlassConstructor | MediaNode, // TODO - Alignment ParagraphNode, LinkNode, - { - replace: TableNode, - with(node: TableNode) { - return new CustomTableNode(); - } - }, - { - replace: TableRowNode, - with(node: TableRowNode) { - return new CustomTableRowNode(); - } - }, - { - replace: TableCellNode, - with: (node: TableCellNode) => { - const cell = new CustomTableCellNode( - node.__headerState, - node.__colSpan, - node.__width, - ); - cell.__rowSpan = node.__rowSpan; - return cell; - } - }, ]; } diff --git a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts index fc4196f0a08..2e4883d88e6 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/tables.ts @@ -9,17 +9,15 @@ import insertRowAboveIcon from "@icons/editor/table-insert-row-above.svg"; import insertRowBelowIcon from "@icons/editor/table-insert-row-below.svg"; import {EditorUiContext} from "../../framework/core"; import {$getSelection, BaseSelection} from "lexical"; -import {$isCustomTableNode} from "../../../nodes/custom-table"; import { $deleteTableColumn__EXPERIMENTAL, $deleteTableRow__EXPERIMENTAL, $insertTableColumn__EXPERIMENTAL, - $insertTableRow__EXPERIMENTAL, - $isTableNode, $isTableSelection, $unmergeCell, TableCellNode, + $insertTableRow__EXPERIMENTAL, $isTableCellNode, + $isTableNode, $isTableRowNode, $isTableSelection, $unmergeCell, TableCellNode, } from "@lexical/table"; import {$getNodeFromSelection, $selectionContainsNodeType} from "../../../utils/selection"; import {$getParentOfType} from "../../../utils/nodes"; -import {$isCustomTableCellNode} from "../../../nodes/custom-table-cell"; import {$showCellPropertiesForm, $showRowPropertiesForm, $showTablePropertiesForm} from "../forms/tables"; import { $clearTableFormatting, @@ -27,7 +25,6 @@ import { $getTableRowsFromSelection, $mergeTableCellsInSelection } from "../../../utils/tables"; -import {$isCustomTableRowNode} from "../../../nodes/custom-table-row"; import { $copySelectedColumnsToClipboard, $copySelectedRowsToClipboard, @@ -41,7 +38,7 @@ import { } from "../../../utils/table-copy-paste"; const neverActive = (): boolean => false; -const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isCustomTableCellNode); +const cellNotSelected = (selection: BaseSelection|null) => !$selectionContainsNodeType(selection, $isTableCellNode); export const table: EditorBasicButtonDefinition = { label: 'Table', @@ -54,7 +51,7 @@ export const tableProperties: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const table = $getTableFromSelection($getSelection()); - if ($isCustomTableNode(table)) { + if ($isTableNode(table)) { $showTablePropertiesForm(table, context); } }); @@ -68,13 +65,13 @@ export const clearTableFormatting: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { return; } const table = $getParentOfType(cell, $isTableNode); - if ($isCustomTableNode(table)) { + if ($isTableNode(table)) { $clearTableFormatting(table); } }); @@ -88,13 +85,13 @@ export const resizeTableToContents: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.update(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if (!$isCustomTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if (!$isTableCellNode(cell)) { return; } - const table = $getParentOfType(cell, $isCustomTableNode); - if ($isCustomTableNode(table)) { + const table = $getParentOfType(cell, $isTableNode); + if ($isTableNode(table)) { $clearTableSizes(table); } }); @@ -108,7 +105,7 @@ export const deleteTable: EditorButtonDefinition = { icon: deleteIcon, action(context: EditorUiContext) { context.editor.update(() => { - const table = $getNodeFromSelection($getSelection(), $isCustomTableNode); + const table = $getNodeFromSelection($getSelection(), $isTableNode); if (table) { table.remove(); } @@ -169,7 +166,7 @@ export const rowProperties: EditorButtonDefinition = { action(context: EditorUiContext) { context.editor.getEditorState().read(() => { const rows = $getTableRowsFromSelection($getSelection()); - if ($isCustomTableRowNode(rows[0])) { + if ($isTableRowNode(rows[0])) { $showRowPropertiesForm(rows[0], context); } }); @@ -350,8 +347,8 @@ export const cellProperties: EditorButtonDefinition = { format: 'long', action(context: EditorUiContext) { context.editor.getEditorState().read(() => { - const cell = $getNodeFromSelection($getSelection(), $isCustomTableCellNode); - if ($isCustomTableCellNode(cell)) { + const cell = $getNodeFromSelection($getSelection(), $isTableCellNode); + if ($isTableCellNode(cell)) { $showCellPropertiesForm(cell, context); } }); @@ -387,7 +384,7 @@ export const splitCell: EditorButtonDefinition = { }, isActive: neverActive, isDisabled(selection) { - const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as TableCellNode|null; + const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode|null; if (cell) { const merged = cell.getRowSpan() > 1 || cell.getColSpan() > 1; return !merged; diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 5a41c85b3da..3cfe9592ccb 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -5,7 +5,6 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {CustomTableCellNode} from "../../../nodes/custom-table-cell"; import {EditorFormModal} from "../../framework/modals"; import {$getSelection, ElementFormatType} from "lexical"; import { @@ -16,8 +15,8 @@ import { $setTableCellColumnWidth } from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; -import {CustomTableRowNode} from "../../../nodes/custom-table-row"; -import {CustomTableNode} from "../../../nodes/custom-table"; +import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; +import {CommonBlockAlignment} from "../../../nodes/_common"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', @@ -62,14 +61,14 @@ const alignmentInput: EditorSelectFormFieldDefinition = { } }; -export function $showCellPropertiesForm(cell: CustomTableCellNode, context: EditorUiContext): EditorFormModal { +export function $showCellPropertiesForm(cell: TableCellNode, context: EditorUiContext): EditorFormModal { const styles = cell.getStyles(); const modalForm = context.manager.createModal('cell_properties'); modalForm.show({ width: $getTableCellColumnWidth(context.editor, cell), height: styles.get('height') || '', type: cell.getTag(), - h_align: cell.getFormatType(), + h_align: cell.getAlignment(), v_align: styles.get('vertical-align') || '', border_width: styles.get('border-width') || '', border_style: styles.get('border-style') || '', @@ -89,7 +88,7 @@ export const cellProperties: EditorFormDefinition = { $setTableCellColumnWidth(cell, width); cell.updateTag(formData.get('type')?.toString() || ''); - cell.setFormat((formData.get('h_align')?.toString() || '') as ElementFormatType); + cell.setAlignment((formData.get('h_align')?.toString() || '') as CommonBlockAlignment); const styles = cell.getStyles(); styles.set('height', formatSizeValue(formData.get('height')?.toString() || '')); @@ -172,7 +171,7 @@ export const cellProperties: EditorFormDefinition = { ], }; -export function $showRowPropertiesForm(row: CustomTableRowNode, context: EditorUiContext): EditorFormModal { +export function $showRowPropertiesForm(row: TableRowNode, context: EditorUiContext): EditorFormModal { const styles = row.getStyles(); const modalForm = context.manager.createModal('row_properties'); modalForm.show({ @@ -216,7 +215,7 @@ export const rowProperties: EditorFormDefinition = { ], }; -export function $showTablePropertiesForm(table: CustomTableNode, context: EditorUiContext): EditorFormModal { +export function $showTablePropertiesForm(table: TableNode, context: EditorUiContext): EditorFormModal { const styles = table.getStyles(); const modalForm = context.manager.createModal('table_properties'); modalForm.show({ @@ -229,7 +228,7 @@ export function $showTablePropertiesForm(table: CustomTableNode, context: Editor border_color: styles.get('border-color') || '', background_color: styles.get('background-color') || '', // caption: '', TODO - align: table.getFormatType(), + align: table.getAlignment(), }); return modalForm; } @@ -253,12 +252,12 @@ export const tableProperties: EditorFormDefinition = { styles.set('background-color', formData.get('background_color')?.toString() || ''); table.setStyles(styles); - table.setFormat(formData.get('align') as ElementFormatType); + table.setAlignment(formData.get('align') as CommonBlockAlignment); const cellPadding = (formData.get('cell_padding')?.toString() || ''); if (cellPadding) { const cellPaddingFormatted = formatSizeValue(cellPadding); - $forEachTableCell(table, (cell: CustomTableCellNode) => { + $forEachTableCell(table, (cell: TableCellNode) => { const styles = cell.getStyles(); styles.set('padding', cellPaddingFormatted); cell.setStyles(styles); diff --git a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts index 30ff3abc56c..6f026ca1895 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/table-creator.ts @@ -1,6 +1,5 @@ import {EditorUiElement} from "../core"; import {$createTableNodeWithDimensions} from "@lexical/table"; -import {CustomTableNode} from "../../../nodes/custom-table"; import {$insertNewBlockNodeAtSelection} from "../../../utils/selection"; import {el} from "../../../utils/dom"; @@ -78,7 +77,7 @@ export class EditorTableCreator extends EditorUiElement { const colWidths = Array(columns).fill(targetColWidth + 'px'); this.getContext().editor.update(() => { - const table = $createTableNodeWithDimensions(rows, columns, false) as CustomTableNode; + const table = $createTableNodeWithDimensions(rows, columns, false); table.setColWidths(colWidths); $insertNewBlockNodeAtSelection(table); }); diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts index 37f1b6f01ee..4256fdafc65 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-resizer.ts @@ -1,7 +1,6 @@ import {$getNearestNodeFromDOMNode, LexicalEditor} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; -import {CustomTableNode} from "../../../nodes/custom-table"; -import {TableRowNode} from "@lexical/table"; +import {TableNode, TableRowNode} from "@lexical/table"; import {el} from "../../../utils/dom"; import {$getTableColumnWidth, $setTableColumnWidth} from "../../../utils/tables"; @@ -148,7 +147,7 @@ class TableResizer { _this.editor.update(() => { const table = $getNearestNodeFromDOMNode(parentTable); - if (table instanceof CustomTableNode) { + if (table instanceof TableNode) { const originalWidth = $getTableColumnWidth(_this.editor, table, cellIndex); const newWidth = Math.max(originalWidth + change, 10); $setTableColumnWidth(table, cellIndex, newWidth); diff --git a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts index f631fb804a5..d3d8925505f 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/table-selection-handler.ts @@ -1,12 +1,12 @@ import {$getNodeByKey, LexicalEditor} from "lexical"; import {NodeKey} from "lexical/LexicalNode"; import { + $isTableNode, applyTableHandlers, HTMLTableElementWithWithTableSelectionState, TableNode, TableObserver } from "@lexical/table"; -import {$isCustomTableNode, CustomTableNode} from "../../../nodes/custom-table"; // File adapted from logic in: // https://github.com/facebook/lexical/blob/f373759a7849f473d34960a6bf4e34b2a011e762/packages/lexical-react/src/LexicalTablePlugin.ts#L49 @@ -25,12 +25,12 @@ class TableSelectionHandler { } protected init() { - this.unregisterMutationListener = this.editor.registerMutationListener(CustomTableNode, (mutations) => { + this.unregisterMutationListener = this.editor.registerMutationListener(TableNode, (mutations) => { for (const [nodeKey, mutation] of mutations) { if (mutation === 'created') { this.editor.getEditorState().read(() => { - const tableNode = $getNodeByKey(nodeKey); - if ($isCustomTableNode(tableNode)) { + const tableNode = $getNodeByKey(nodeKey); + if ($isTableNode(tableNode)) { this.initializeTableNode(tableNode); } }); diff --git a/resources/js/wysiwyg/utils/table-copy-paste.ts b/resources/js/wysiwyg/utils/table-copy-paste.ts index 12c19b0fb80..1e024e4c758 100644 --- a/resources/js/wysiwyg/utils/table-copy-paste.ts +++ b/resources/js/wysiwyg/utils/table-copy-paste.ts @@ -1,24 +1,28 @@ import {NodeClipboard} from "./node-clipboard"; -import {CustomTableRowNode} from "../nodes/custom-table-row"; import {$getTableCellsFromSelection, $getTableFromSelection, $getTableRowsFromSelection} from "./tables"; import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; -import {$createCustomTableCellNode, $isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; -import {CustomTableNode} from "../nodes/custom-table"; import {TableMap} from "./table-map"; -import {$isTableSelection} from "@lexical/table"; +import { + $createTableCellNode, + $isTableCellNode, + $isTableSelection, + TableCellNode, + TableNode, + TableRowNode +} from "@lexical/table"; import {$getNodeFromSelection} from "./selection"; -const rowClipboard: NodeClipboard = new NodeClipboard(); +const rowClipboard: NodeClipboard = new NodeClipboard(); export function isRowClipboardEmpty(): boolean { return rowClipboard.size() === 0; } -export function validateRowsToCopy(rows: CustomTableRowNode[]): void { +export function validateRowsToCopy(rows: TableRowNode[]): void { let commonRowSize: number|null = null; for (const row of rows) { - const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + const cells = row.getChildren().filter(n => $isTableCellNode(n)); let rowSize = 0; for (const cell of cells) { rowSize += cell.getColSpan() || 1; @@ -35,10 +39,10 @@ export function validateRowsToCopy(rows: CustomTableRowNode[]): void { } } -export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: CustomTableNode): void { +export function validateRowsToPaste(rows: TableRowNode[], targetTable: TableNode): void { const tableColCount = (new TableMap(targetTable)).columnCount; for (const row of rows) { - const cells = row.getChildren().filter(n => $isCustomTableCellNode(n)); + const cells = row.getChildren().filter(n => $isTableCellNode(n)); let rowSize = 0; for (const cell of cells) { rowSize += cell.getColSpan() || 1; @@ -49,7 +53,7 @@ export function validateRowsToPaste(rows: CustomTableRowNode[], targetTable: Cus } while (rowSize < tableColCount) { - row.append($createCustomTableCellNode()); + row.append($createTableCellNode()); rowSize++; } } @@ -98,11 +102,11 @@ export function $pasteClipboardRowsAfter(editor: LexicalEditor): void { } } -const columnClipboard: NodeClipboard[] = []; +const columnClipboard: NodeClipboard[] = []; -function setColumnClipboard(columns: CustomTableCellNode[][]): void { +function setColumnClipboard(columns: TableCellNode[][]): void { const newClipboards = columns.map(cells => { - const clipboard = new NodeClipboard(); + const clipboard = new NodeClipboard(); clipboard.set(...cells); return clipboard; }); @@ -122,9 +126,9 @@ function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|nul return {from: shape.fromX, to: shape.toX}; } - const cell = $getNodeFromSelection(selection, $isCustomTableCellNode); + const cell = $getNodeFromSelection(selection, $isTableCellNode); const table = $getTableFromSelection(selection); - if (!$isCustomTableCellNode(cell) || !table) { + if (!$isTableCellNode(cell) || !table) { return null; } @@ -137,7 +141,7 @@ function $getSelectionColumnRange(selection: BaseSelection|null): TableRange|nul return {from: range.fromX, to: range.toX}; } -function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTableNode): CustomTableCellNode[][] { +function $getTableColumnCellsFromSelection(range: TableRange, table: TableNode): TableCellNode[][] { const map = new TableMap(table); const columns = []; for (let x = range.from; x <= range.to; x++) { @@ -148,7 +152,7 @@ function $getTableColumnCellsFromSelection(range: TableRange, table: CustomTable return columns; } -function validateColumnsToCopy(columns: CustomTableCellNode[][]): void { +function validateColumnsToCopy(columns: TableCellNode[][]): void { let commonColSize: number|null = null; for (const cells of columns) { @@ -203,7 +207,7 @@ export function $copySelectedColumnsToClipboard(): void { setColumnClipboard(columns); } -function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: CustomTableNode) { +function validateColumnsToPaste(columns: TableCellNode[][], targetTable: TableNode) { const tableRowCount = (new TableMap(targetTable)).rowCount; for (const cells of columns) { let colSize = 0; @@ -216,7 +220,7 @@ function validateColumnsToPaste(columns: CustomTableCellNode[][], targetTable: C } while (colSize < tableRowCount) { - cells.push($createCustomTableCellNode()); + cells.push($createTableCellNode()); colSize++; } } diff --git a/resources/js/wysiwyg/utils/table-map.ts b/resources/js/wysiwyg/utils/table-map.ts index 607deffe1ca..dfe21b936f6 100644 --- a/resources/js/wysiwyg/utils/table-map.ts +++ b/resources/js/wysiwyg/utils/table-map.ts @@ -1,6 +1,4 @@ -import {CustomTableNode} from "../nodes/custom-table"; -import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; -import {$isTableRowNode} from "@lexical/table"; +import {$isTableCellNode, $isTableRowNode, TableCellNode, TableNode} from "@lexical/table"; export type CellRange = { fromX: number; @@ -16,15 +14,15 @@ export class TableMap { // Represents an array (rows*columns in length) of cell nodes from top-left to // bottom right. Cells may repeat where merged and covering multiple spaces. - cells: CustomTableCellNode[] = []; + cells: TableCellNode[] = []; - constructor(table: CustomTableNode) { + constructor(table: TableNode) { this.buildCellMap(table); } - protected buildCellMap(table: CustomTableNode) { - const rowsAndCells: CustomTableCellNode[][] = []; - const setCell = (x: number, y: number, cell: CustomTableCellNode) => { + protected buildCellMap(table: TableNode) { + const rowsAndCells: TableCellNode[][] = []; + const setCell = (x: number, y: number, cell: TableCellNode) => { if (typeof rowsAndCells[y] === 'undefined') { rowsAndCells[y] = []; } @@ -36,7 +34,7 @@ export class TableMap { const rowNodes = table.getChildren().filter(r => $isTableRowNode(r)); for (let rowIndex = 0; rowIndex < rowNodes.length; rowIndex++) { const rowNode = rowNodes[rowIndex]; - const cellNodes = rowNode.getChildren().filter(c => $isCustomTableCellNode(c)); + const cellNodes = rowNode.getChildren().filter(c => $isTableCellNode(c)); let targetColIndex: number = 0; for (let cellIndex = 0; cellIndex < cellNodes.length; cellIndex++) { const cellNode = cellNodes[cellIndex]; @@ -60,7 +58,7 @@ export class TableMap { this.columnCount = Math.max(...rowsAndCells.map(r => r.length)); const cells = []; - let lastCell: CustomTableCellNode = rowsAndCells[0][0]; + let lastCell: TableCellNode = rowsAndCells[0][0]; for (let y = 0; y < this.rowCount; y++) { for (let x = 0; x < this.columnCount; x++) { if (!rowsAndCells[y] || !rowsAndCells[y][x]) { @@ -75,7 +73,7 @@ export class TableMap { this.cells = cells; } - public getCellAtPosition(x: number, y: number): CustomTableCellNode { + public getCellAtPosition(x: number, y: number): TableCellNode { const position = (y * this.columnCount) + x; if (position >= this.cells.length) { throw new Error(`TableMap Error: Attempted to get cell ${position+1} of ${this.cells.length}`); @@ -84,13 +82,13 @@ export class TableMap { return this.cells[position]; } - public getCellsInRange(range: CellRange): CustomTableCellNode[] { + public getCellsInRange(range: CellRange): TableCellNode[] { const minX = Math.max(Math.min(range.fromX, range.toX), 0); const maxX = Math.min(Math.max(range.fromX, range.toX), this.columnCount - 1); const minY = Math.max(Math.min(range.fromY, range.toY), 0); const maxY = Math.min(Math.max(range.fromY, range.toY), this.rowCount - 1); - const cells = new Set(); + const cells = new Set(); for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) { @@ -101,7 +99,7 @@ export class TableMap { return [...cells.values()]; } - public getCellsInColumn(columnIndex: number): CustomTableCellNode[] { + public getCellsInColumn(columnIndex: number): TableCellNode[] { return this.getCellsInRange({ fromX: columnIndex, toX: columnIndex, @@ -110,7 +108,7 @@ export class TableMap { }); } - public getRangeForCell(cell: CustomTableCellNode): CellRange|null { + public getRangeForCell(cell: TableCellNode): CellRange|null { let range: CellRange|null = null; const cellKey = cell.getKey(); diff --git a/resources/js/wysiwyg/utils/tables.ts b/resources/js/wysiwyg/utils/tables.ts index aa8ec89ba77..ed947ddcdcb 100644 --- a/resources/js/wysiwyg/utils/tables.ts +++ b/resources/js/wysiwyg/utils/tables.ts @@ -1,15 +1,19 @@ import {BaseSelection, LexicalEditor} from "lexical"; -import {$isTableRowNode, $isTableSelection, TableRowNode, TableSelection, TableSelectionShape} from "@lexical/table"; -import {$isCustomTableNode, CustomTableNode} from "../nodes/custom-table"; -import {$isCustomTableCellNode, CustomTableCellNode} from "../nodes/custom-table-cell"; +import { + $isTableCellNode, + $isTableNode, + $isTableRowNode, + $isTableSelection, TableCellNode, TableNode, + TableRowNode, + TableSelection, +} from "@lexical/table"; import {$getParentOfType} from "./nodes"; import {$getNodeFromSelection} from "./selection"; import {formatSizeValue} from "./dom"; import {TableMap} from "./table-map"; -import {$isCustomTableRowNode, CustomTableRowNode} from "../nodes/custom-table-row"; -function $getTableFromCell(cell: CustomTableCellNode): CustomTableNode|null { - return $getParentOfType(cell, $isCustomTableNode) as CustomTableNode|null; +function $getTableFromCell(cell: TableCellNode): TableNode|null { + return $getParentOfType(cell, $isTableNode) as TableNode|null; } export function getTableColumnWidths(table: HTMLTableElement): string[] { @@ -55,7 +59,7 @@ function extractWidthFromElement(element: HTMLElement): string { return width || ''; } -export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, width: number|string): void { +export function $setTableColumnWidth(node: TableNode, columnIndex: number, width: number|string): void { const rows = node.getChildren() as TableRowNode[]; let maxCols = 0; for (const row of rows) { @@ -78,7 +82,7 @@ export function $setTableColumnWidth(node: CustomTableNode, columnIndex: number, node.setColWidths(colWidths); } -export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNode, columnIndex: number): number { +export function $getTableColumnWidth(editor: LexicalEditor, node: TableNode, columnIndex: number): number { const colWidths = node.getColWidths(); if (colWidths.length > columnIndex && colWidths[columnIndex].endsWith('px')) { return Number(colWidths[columnIndex].replace('px', '')); @@ -97,14 +101,14 @@ export function $getTableColumnWidth(editor: LexicalEditor, node: CustomTableNod return 0; } -function $getCellColumnIndex(node: CustomTableCellNode): number { +function $getCellColumnIndex(node: TableCellNode): number { const row = node.getParent(); if (!$isTableRowNode(row)) { return -1; } let index = 0; - const cells = row.getChildren(); + const cells = row.getChildren(); for (const cell of cells) { let colSpan = cell.getColSpan() || 1; index += colSpan; @@ -116,7 +120,7 @@ function $getCellColumnIndex(node: CustomTableCellNode): number { return index - 1; } -export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: string): void { +export function $setTableCellColumnWidth(cell: TableCellNode, width: string): void { const table = $getTableFromCell(cell) const index = $getCellColumnIndex(cell); @@ -125,7 +129,7 @@ export function $setTableCellColumnWidth(cell: CustomTableCellNode, width: strin } } -export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTableCellNode): string { +export function $getTableCellColumnWidth(editor: LexicalEditor, cell: TableCellNode): string { const table = $getTableFromCell(cell) const index = $getCellColumnIndex(cell); if (!table) { @@ -136,13 +140,13 @@ export function $getTableCellColumnWidth(editor: LexicalEditor, cell: CustomTabl return (widths.length > index) ? widths[index] : ''; } -export function $getTableCellsFromSelection(selection: BaseSelection|null): CustomTableCellNode[] { +export function $getTableCellsFromSelection(selection: BaseSelection|null): TableCellNode[] { if ($isTableSelection(selection)) { const nodes = selection.getNodes(); - return nodes.filter(n => $isCustomTableCellNode(n)); + return nodes.filter(n => $isTableCellNode(n)); } - const cell = $getNodeFromSelection(selection, $isCustomTableCellNode) as CustomTableCellNode; + const cell = $getNodeFromSelection(selection, $isTableCellNode) as TableCellNode; return cell ? [cell] : []; } @@ -193,12 +197,12 @@ export function $mergeTableCellsInSelection(selection: TableSelection): void { firstCell.setRowSpan(newHeight); } -export function $getTableRowsFromSelection(selection: BaseSelection|null): CustomTableRowNode[] { +export function $getTableRowsFromSelection(selection: BaseSelection|null): TableRowNode[] { const cells = $getTableCellsFromSelection(selection); - const rowsByKey: Record = {}; + const rowsByKey: Record = {}; for (const cell of cells) { const row = cell.getParent(); - if ($isCustomTableRowNode(row)) { + if ($isTableRowNode(row)) { rowsByKey[row.getKey()] = row; } } @@ -206,28 +210,28 @@ export function $getTableRowsFromSelection(selection: BaseSelection|null): Custo return Object.values(rowsByKey); } -export function $getTableFromSelection(selection: BaseSelection|null): CustomTableNode|null { +export function $getTableFromSelection(selection: BaseSelection|null): TableNode|null { const cells = $getTableCellsFromSelection(selection); if (cells.length === 0) { return null; } - const table = $getParentOfType(cells[0], $isCustomTableNode); - if ($isCustomTableNode(table)) { + const table = $getParentOfType(cells[0], $isTableNode); + if ($isTableNode(table)) { return table; } return null; } -export function $clearTableSizes(table: CustomTableNode): void { +export function $clearTableSizes(table: TableNode): void { table.setColWidths([]); // TODO - Extra form things once table properties and extra things // are supported for (const row of table.getChildren()) { - if (!$isCustomTableRowNode(row)) { + if (!$isTableRowNode(row)) { continue; } @@ -236,7 +240,7 @@ export function $clearTableSizes(table: CustomTableNode): void { rowStyles.delete('width'); row.setStyles(rowStyles); - const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + const cells = row.getChildren().filter(c => $isTableCellNode(c)); for (const cell of cells) { const cellStyles = cell.getStyles(); cellStyles.delete('height'); @@ -247,23 +251,21 @@ export function $clearTableSizes(table: CustomTableNode): void { } } -export function $clearTableFormatting(table: CustomTableNode): void { +export function $clearTableFormatting(table: TableNode): void { table.setColWidths([]); table.setStyles(new Map); for (const row of table.getChildren()) { - if (!$isCustomTableRowNode(row)) { + if (!$isTableRowNode(row)) { continue; } row.setStyles(new Map); - row.setFormat(''); - const cells = row.getChildren().filter(c => $isCustomTableCellNode(c)); + const cells = row.getChildren().filter(c => $isTableCellNode(c)); for (const cell of cells) { cell.setStyles(new Map); cell.clearWidth(); - cell.setFormat(''); } } } @@ -272,14 +274,14 @@ export function $clearTableFormatting(table: CustomTableNode): void { * Perform the given callback for each cell in the given table. * Returning false from the callback stops the function early. */ -export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTableCellNode) => void|false): void { +export function $forEachTableCell(table: TableNode, callback: (c: TableCellNode) => void|false): void { outer: for (const row of table.getChildren()) { - if (!$isCustomTableRowNode(row)) { + if (!$isTableRowNode(row)) { continue; } const cells = row.getChildren(); for (const cell of cells) { - if (!$isCustomTableCellNode(cell)) { + if (!$isTableCellNode(cell)) { return; } const result = callback(cell); @@ -290,10 +292,10 @@ export function $forEachTableCell(table: CustomTableNode, callback: (c: CustomTa } } -export function $getCellPaddingForTable(table: CustomTableNode): string { +export function $getCellPaddingForTable(table: TableNode): string { let padding: string|null = null; - $forEachTableCell(table, (cell: CustomTableCellNode) => { + $forEachTableCell(table, (cell: TableCellNode) => { const cellPadding = cell.getStyles().get('padding') || '' if (padding === null) { padding = cellPadding; From 9fdd100f2d989ddc30d9cbad4dadb1b98096edaf Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 4 Dec 2024 18:53:59 +0000 Subject: [PATCH 61/72] Lexical: Reorganised custom node code into lexical codebase Also cleaned up old unused imports. --- resources/js/wysiwyg/index.ts | 2 +- .../js/wysiwyg/lexical/core/LexicalMutations.ts | 1 - .../js/wysiwyg/lexical/core/LexicalReconciler.ts | 12 ++++++------ .../core/__tests__/unit/LexicalEditor.test.ts | 1 - .../lexical/core/nodes/CommonBlockNode.ts | 13 ++++++++++--- .../lexical/core/nodes/LexicalElementNode.ts | 4 ++-- .../lexical/core/nodes/LexicalParagraphNode.ts | 6 +++--- .../nodes/__tests__/unit/LexicalTabNode.test.ts | 10 +--------- .../nodes/__tests__/unit/LexicalTextNode.test.ts | 2 -- .../_common.ts => lexical/core/nodes/common.ts} | 13 +++---------- .../js/wysiwyg/lexical/list/LexicalListNode.ts | 2 +- resources/js/wysiwyg/lexical/readme.md | 2 +- .../rich-text/LexicalCalloutNode.ts} | 4 ++-- .../rich-text/LexicalCodeBlockNode.ts} | 6 +++--- .../rich-text/LexicalDetailsNode.ts} | 4 ++-- .../rich-text/LexicalDiagramNode.ts} | 4 ++-- .../lexical/rich-text/LexicalHeadingNode.ts | 7 +++---- .../rich-text/LexicalHorizontalRuleNode.ts} | 0 .../rich-text/LexicalImageNode.ts} | 4 ++-- .../rich-text/LexicalMediaNode.ts} | 8 ++++---- .../lexical/rich-text/LexicalQuoteNode.ts | 10 ++++------ .../lexical/table/LexicalTableCellNode.ts | 2 +- .../js/wysiwyg/lexical/table/LexicalTableNode.ts | 10 +++++----- .../table/LexicalTableSelectionHelpers.ts | 2 -- .../js/wysiwyg/{nodes/index.ts => nodes.ts} | 16 ++++++++-------- .../js/wysiwyg/services/drop-paste-handling.ts | 2 +- .../js/wysiwyg/services/keyboard-handling.ts | 4 ++-- resources/js/wysiwyg/ui/decorators/code-block.ts | 4 ++-- resources/js/wysiwyg/ui/decorators/diagram.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/alignments.ts | 4 ++-- .../wysiwyg/ui/defaults/buttons/block-formats.ts | 2 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 15 +++++++-------- .../js/wysiwyg/ui/defaults/forms/objects.ts | 9 ++++----- resources/js/wysiwyg/ui/defaults/forms/tables.ts | 4 ++-- .../wysiwyg/ui/framework/helpers/node-resizer.ts | 6 +++--- resources/js/wysiwyg/ui/framework/manager.ts | 2 +- resources/js/wysiwyg/utils/diagrams.ts | 4 ++-- resources/js/wysiwyg/utils/formats.ts | 4 ++-- resources/js/wysiwyg/utils/images.ts | 2 +- resources/js/wysiwyg/utils/nodes.ts | 2 +- resources/js/wysiwyg/utils/selection.ts | 2 +- 41 files changed, 97 insertions(+), 116 deletions(-) rename resources/js/wysiwyg/{nodes/_common.ts => lexical/core/nodes/common.ts} (89%) rename resources/js/wysiwyg/{nodes/callout.ts => lexical/rich-text/LexicalCalloutNode.ts} (98%) rename resources/js/wysiwyg/{nodes/code-block.ts => lexical/rich-text/LexicalCodeBlockNode.ts} (97%) rename resources/js/wysiwyg/{nodes/details.ts => lexical/rich-text/LexicalDetailsNode.ts} (97%) rename resources/js/wysiwyg/{nodes/diagram.ts => lexical/rich-text/LexicalDiagramNode.ts} (97%) rename resources/js/wysiwyg/{nodes/horizontal-rule.ts => lexical/rich-text/LexicalHorizontalRuleNode.ts} (100%) rename resources/js/wysiwyg/{nodes/image.ts => lexical/rich-text/LexicalImageNode.ts} (98%) rename resources/js/wysiwyg/{nodes/media.ts => lexical/rich-text/LexicalMediaNode.ts} (97%) rename resources/js/wysiwyg/{nodes/index.ts => nodes.ts} (78%) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index c4403773bf2..9066b402f33 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -1,4 +1,4 @@ -import {$getSelection, createEditor, CreateEditorArgs, isCurrentlyReadOnlyMode, LexicalEditor} from 'lexical'; +import {$getSelection, createEditor, CreateEditorArgs, LexicalEditor} from 'lexical'; import {createEmptyHistoryState, registerHistory} from '@lexical/history'; import {registerRichText} from '@lexical/rich-text'; import {mergeRegister} from '@lexical/utils'; diff --git a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts index c24dc9ebb3c..80645205679 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalMutations.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalMutations.ts @@ -16,7 +16,6 @@ import { $getSelection, $isDecoratorNode, $isElementNode, - $isRangeSelection, $isTextNode, $setSelection, } from '.'; diff --git a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts index 7843027d713..fccf1ae23a8 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalReconciler.ts @@ -29,12 +29,12 @@ import { import { DOUBLE_LINE_BREAK, FULL_RECONCILE, - IS_ALIGN_CENTER, - IS_ALIGN_END, - IS_ALIGN_JUSTIFY, - IS_ALIGN_LEFT, - IS_ALIGN_RIGHT, - IS_ALIGN_START, + + + + + + } from './LexicalConstants'; import {EditorState} from './LexicalEditorState'; import { diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts index f3c6f710509..28a203100c4 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -47,7 +47,6 @@ import { import invariant from 'lexical/shared/invariant'; import { - $createTestDecoratorNode, $createTestElementNode, $createTestInlineElementNode, createTestEditor, diff --git a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts index bf4fc08ca60..572c9448b0c 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/CommonBlockNode.ts @@ -1,8 +1,15 @@ -import {ElementNode} from "./LexicalElementNode"; -import {CommonBlockAlignment, SerializedCommonBlockNode} from "../../../nodes/_common"; +import {ElementNode, type SerializedElementNode} from "./LexicalElementNode"; +import {CommonBlockAlignment, CommonBlockInterface} from "./common"; +import {Spread} from "lexical"; -export class CommonBlockNode extends ElementNode { +export type SerializedCommonBlockNode = Spread<{ + id: string; + alignment: CommonBlockAlignment; + inset: number; +}, SerializedElementNode> + +export class CommonBlockNode extends ElementNode implements CommonBlockInterface { __id: string = ''; __alignment: CommonBlockAlignment = ''; __inset: number = 0; diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts index 002d825d6ea..9624af67e7e 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalElementNode.ts @@ -19,8 +19,8 @@ import invariant from 'lexical/shared/invariant'; import {$isTextNode, TextNode} from '../index'; import { DOUBLE_LINE_BREAK, - ELEMENT_FORMAT_TO_TYPE, - ELEMENT_TYPE_TO_FORMAT, + + } from '../LexicalConstants'; import {LexicalNode} from '../LexicalNode'; import { diff --git a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts index 6517d939eda..f6f57c91c75 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/LexicalParagraphNode.ts @@ -29,10 +29,10 @@ import { import {$isTextNode} from './LexicalTextNode'; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../../nodes/_common"; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +} from "./common"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type SerializedParagraphNode = Spread< { diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts index 9831114340d..d1ba5359752 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTabNode.test.ts @@ -10,21 +10,14 @@ import { $insertDataTransferForPlainText, $insertDataTransferForRichText, } from '@lexical/clipboard'; -import {$createListItemNode, $createListNode} from '@lexical/list'; -import {registerRichText} from '@lexical/rich-text'; import { $createParagraphNode, - $createRangeSelection, $createTabNode, - $createTextNode, $getRoot, $getSelection, $insertNodes, - $isElementNode, $isRangeSelection, - $isTextNode, - $setSelection, - KEY_TAB_COMMAND, + } from 'lexical'; import { @@ -32,7 +25,6 @@ import { initializeUnitTest, invariant, } from '../../../__tests__/utils'; -import {$createHeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; describe('LexicalTabNode tests', () => { initializeUnitTest((testEnv) => { diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts index b1ea099ac1e..c54760ff242 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalTextNode.test.ts @@ -41,9 +41,7 @@ import { $setCompositionKey, getEditorStateTextContent, } from '../../../LexicalUtils'; -import {Text} from "@codemirror/state"; import {$generateHtmlFromNodes} from "@lexical/html"; -import {formatBold} from "@lexical/selection/__tests__/utils"; const editorConfig = Object.freeze({ namespace: '', diff --git a/resources/js/wysiwyg/nodes/_common.ts b/resources/js/wysiwyg/lexical/core/nodes/common.ts similarity index 89% rename from resources/js/wysiwyg/nodes/_common.ts rename to resources/js/wysiwyg/lexical/core/nodes/common.ts index 71849bb4589..eac9c829595 100644 --- a/resources/js/wysiwyg/nodes/_common.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/common.ts @@ -1,18 +1,11 @@ -import {LexicalNode, Spread} from "lexical"; -import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; -import {el, sizeToPixels} from "../utils/dom"; +import {sizeToPixels} from "../../../utils/dom"; +import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | ''; const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify']; type EditorNodeDirection = 'ltr' | 'rtl' | null; -export type SerializedCommonBlockNode = Spread<{ - id: string; - alignment: CommonBlockAlignment; - inset: number; -}, SerializedElementNode> - export interface NodeHasAlignment { readonly __alignment: CommonBlockAlignment; setAlignment(alignment: CommonBlockAlignment): void; @@ -37,7 +30,7 @@ export interface NodeHasDirection { getDirection(): EditorNodeDirection; } -interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset, NodeHasDirection {} +export interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset, NodeHasDirection {} export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment { const textAlignStyle: string = element.style.textAlign || ''; diff --git a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts index 138c895e6b8..6edf0d64a2e 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListNode.ts @@ -36,7 +36,7 @@ import { updateChildrenListItemValue, } from './formatList'; import {$getListDepth, $wrapInListItem} from './utils'; -import {extractDirectionFromElement} from "../../nodes/_common"; +import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedListNode = Spread< { diff --git a/resources/js/wysiwyg/lexical/readme.md b/resources/js/wysiwyg/lexical/readme.md index 31db8fab1ca..24440ec8077 100644 --- a/resources/js/wysiwyg/lexical/readme.md +++ b/resources/js/wysiwyg/lexical/readme.md @@ -9,4 +9,4 @@ Only components used, or intended to be used, were copied in at this point. The original work built upon in this directory and below is under the copyright of Meta Platforms, Inc. and affiliates. The original license can be seen in the [ORIGINAL-LEXICAL-LICENSE](./ORIGINAL-LEXICAL-LICENSE) file. -Files may have since been modified with modifications being under the license and copyright of the BookStack project as a whole. \ No newline at end of file +Files may have since been added or modified with changes being under the license and copyright of the BookStack project as a whole. \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/callout.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalCalloutNode.ts similarity index 98% rename from resources/js/wysiwyg/nodes/callout.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalCalloutNode.ts index cfe32ec854c..6f97ba751ff 100644 --- a/resources/js/wysiwyg/nodes/callout.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalCalloutNode.ts @@ -11,10 +11,10 @@ import type {EditorConfig} from "lexical/LexicalEditor"; import type {RangeSelection} from "lexical/LexicalSelection"; import { CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "./_common"; +} from "lexical/nodes/common"; +import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success'; diff --git a/resources/js/wysiwyg/nodes/code-block.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/code-block.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts index 76c17197111..cbe69184887 100644 --- a/resources/js/wysiwyg/nodes/code-block.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalCodeBlockNode.ts @@ -8,9 +8,9 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {CodeEditor} from "../../components"; -import {el} from "../utils/dom"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; +import {CodeEditor} from "../../../components"; +import {el} from "../../utils/dom"; export type SerializedCodeBlockNode = Spread<{ language: string; diff --git a/resources/js/wysiwyg/nodes/details.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/details.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index de87696f345..178b0d9531d 100644 --- a/resources/js/wysiwyg/nodes/details.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -8,8 +8,8 @@ import { EditorConfig, } from 'lexical'; -import {el} from "../utils/dom"; -import {extractDirectionFromElement} from "./_common"; +import {el} from "../../utils/dom"; +import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedDetailsNode = Spread<{ id: string; diff --git a/resources/js/wysiwyg/nodes/diagram.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/diagram.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts index bd37b200c80..e69f97848ac 100644 --- a/resources/js/wysiwyg/nodes/diagram.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDiagramNode.ts @@ -8,8 +8,8 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {EditorDecoratorAdapter} from "../ui/framework/decorator"; -import {el} from "../utils/dom"; +import {EditorDecoratorAdapter} from "../../ui/framework/decorator"; +import {el} from "../../utils/dom"; export type SerializedDiagramNode = Spread<{ id: string; diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts index 0f30263ba0e..30563c09d5a 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalHeadingNode.ts @@ -11,16 +11,15 @@ import { type NodeKey, type ParagraphNode, type RangeSelection, - type SerializedElementNode, type Spread } from "lexical"; import {addClassNamesToElement} from "@lexical/utils"; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../nodes/_common"; +} from "lexical/nodes/common"; export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; diff --git a/resources/js/wysiwyg/nodes/horizontal-rule.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalHorizontalRuleNode.ts similarity index 100% rename from resources/js/wysiwyg/nodes/horizontal-rule.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalHorizontalRuleNode.ts diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts similarity index 98% rename from resources/js/wysiwyg/nodes/image.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts index b6d362b62c9..9f42ad73204 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalImageNode.ts @@ -6,8 +6,8 @@ import { Spread } from "lexical"; import type {EditorConfig} from "lexical/LexicalEditor"; -import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; -import {$selectSingleNode} from "../utils/selection"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "lexical/nodes/common"; +import {$selectSingleNode} from "../../utils/selection"; import {SerializedElementNode} from "lexical/nodes/LexicalElementNode"; export interface ImageNodeOptions { diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts similarity index 97% rename from resources/js/wysiwyg/nodes/media.ts rename to resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts index 64fe8f77b4d..a675665ac14 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts @@ -8,14 +8,14 @@ import { } from 'lexical'; import type {EditorConfig} from "lexical/LexicalEditor"; -import {el, setOrRemoveAttribute, sizeToPixels} from "../utils/dom"; +import {el, setOrRemoveAttribute, sizeToPixels} from "../../utils/dom"; import { CommonBlockAlignment, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "./_common"; -import {$selectSingleNode} from "../utils/selection"; +} from "lexical/nodes/common"; +import {$selectSingleNode} from "../../utils/selection"; +import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts index 53caca80115..f0d97fe9806 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalQuoteNode.ts @@ -5,22 +5,20 @@ import { type DOMConversionOutput, type DOMExportOutput, type EditorConfig, - ElementNode, isHTMLElement, type LexicalEditor, LexicalNode, type NodeKey, type ParagraphNode, - type RangeSelection, - SerializedElementNode + type RangeSelection } from "lexical"; import {addClassNamesToElement} from "@lexical/utils"; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../nodes/_common"; +} from "lexical/nodes/common"; export type SerializedQuoteNode = SerializedCommonBlockNode; diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts index 72676b9bacb..1fc6b42bbeb 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableCellNode.ts @@ -29,7 +29,7 @@ import { } from 'lexical'; import {extractStyleMapFromElement, StyleMap} from "../../utils/dom"; -import {CommonBlockAlignment, extractAlignmentFromElement} from "../../nodes/_common"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "lexical/nodes/common"; export const TableCellHeaderStates = { BOTH: 3, diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts index ab163005370..9443747a6f7 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableNode.ts @@ -15,25 +15,25 @@ import { LexicalEditor, LexicalNode, NodeKey, - SerializedElementNode, Spread, + Spread, } from 'lexical'; import {addClassNamesToElement, isHTMLElement} from '@lexical/utils'; import { $applyNodeReplacement, $getNearestNodeFromDOMNode, - ElementNode, + } from 'lexical'; import {$isTableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; import {getTable} from './LexicalTableSelectionHelpers'; -import {CommonBlockNode, copyCommonBlockProperties} from "lexical/nodes/CommonBlockNode"; +import {CommonBlockNode, copyCommonBlockProperties, SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode"; import { commonPropertiesDifferent, deserializeCommonBlockNode, - SerializedCommonBlockNode, setCommonBlockPropsFromElement, + setCommonBlockPropsFromElement, updateElementWithCommonBlockProps -} from "../../nodes/_common"; +} from "lexical/nodes/common"; import {el, extractStyleMapFromElement, StyleMap} from "../../utils/dom"; import {getTableColumnWidths} from "../../utils/tables"; diff --git a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts index 6c3317c5dfa..e098a21e498 100644 --- a/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts +++ b/resources/js/wysiwyg/lexical/table/LexicalTableSelectionHelpers.ts @@ -16,7 +16,6 @@ import type { } from './LexicalTableSelection'; import type { BaseSelection, - ElementFormatType, LexicalCommand, LexicalEditor, LexicalNode, @@ -50,7 +49,6 @@ import { DELETE_LINE_COMMAND, DELETE_WORD_COMMAND, FOCUS_COMMAND, - FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, INSERT_PARAGRAPH_COMMAND, KEY_ARROW_DOWN_COMMAND, diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes.ts similarity index 78% rename from resources/js/wysiwyg/nodes/index.ts rename to resources/js/wysiwyg/nodes.ts index 03213e2629a..eb836bdce02 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -1,4 +1,4 @@ -import {CalloutNode} from './callout'; +import {CalloutNode} from '@lexical/rich-text/LexicalCalloutNode'; import { ElementNode, KlassConstructor, @@ -7,15 +7,15 @@ import { ParagraphNode } from "lexical"; import {LinkNode} from "@lexical/link"; -import {ImageNode} from "./image"; -import {DetailsNode, SummaryNode} from "./details"; +import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; -import {HorizontalRuleNode} from "./horizontal-rule"; -import {CodeBlockNode} from "./code-block"; -import {DiagramNode} from "./diagram"; -import {EditorUiContext} from "../ui/framework/core"; -import {MediaNode} from "./media"; +import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; +import {CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; +import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; +import {EditorUiContext} from "./ui/framework/core"; +import {MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; diff --git a/resources/js/wysiwyg/services/drop-paste-handling.ts b/resources/js/wysiwyg/services/drop-paste-handling.ts index e049d5e7c9e..2ee831d74fc 100644 --- a/resources/js/wysiwyg/services/drop-paste-handling.ts +++ b/resources/js/wysiwyg/services/drop-paste-handling.ts @@ -8,7 +8,7 @@ import { import {$insertNewBlockNodesAtSelection, $selectSingleNode} from "../utils/selection"; import {$getNearestBlockNodeForCoords, $htmlToBlockNodes} from "../utils/nodes"; import {Clipboard} from "../../services/clipboard"; -import {$createImageNode} from "../nodes/image"; +import {$createImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$createLinkNode} from "@lexical/link"; import {EditorImageData, uploadImageFile} from "../utils/images"; import {EditorUiContext} from "../ui/framework/core"; diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 5f7f41ef02c..6a1345fac6d 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -10,8 +10,8 @@ import { LexicalEditor, LexicalNode } from "lexical"; -import {$isImageNode} from "../nodes/image"; -import {$isMediaNode} from "../nodes/media"; +import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {getLastSelection} from "../utils/selection"; import {$getNearestNodeBlockParent} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; diff --git a/resources/js/wysiwyg/ui/decorators/code-block.ts b/resources/js/wysiwyg/ui/decorators/code-block.ts index 37d3df588c3..daae32e1982 100644 --- a/resources/js/wysiwyg/ui/decorators/code-block.ts +++ b/resources/js/wysiwyg/ui/decorators/code-block.ts @@ -1,7 +1,7 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; -import {$openCodeEditorForNode, CodeBlockNode} from "../../nodes/code-block"; -import {$isDecoratorNode, BaseSelection} from "lexical"; +import {$openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; +import {BaseSelection} from "lexical"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; diff --git a/resources/js/wysiwyg/ui/decorators/diagram.ts b/resources/js/wysiwyg/ui/decorators/diagram.ts index 44d332939e8..d53bcb482ec 100644 --- a/resources/js/wysiwyg/ui/decorators/diagram.ts +++ b/resources/js/wysiwyg/ui/decorators/diagram.ts @@ -1,7 +1,7 @@ import {EditorDecorator} from "../framework/decorator"; import {EditorUiContext} from "../framework/core"; import {BaseSelection} from "lexical"; -import {DiagramNode} from "../../nodes/diagram"; +import {DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import {$selectionContainsNode, $selectSingleNode} from "../../utils/selection"; import {$openDrawingEditorForNode} from "../../utils/diagrams"; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index f0f46ddc674..98edf44b3b9 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -9,9 +9,9 @@ import ltrIcon from "@icons/editor/direction-ltr.svg"; import rtlIcon from "@icons/editor/direction-rtl.svg"; import { $getBlockElementNodesInSelection, - $selectionContainsAlignment, $selectionContainsDirection, $selectSingleNode, $toggleSelection, getLastSelection + $selectionContainsAlignment, $selectionContainsDirection, $selectSingleNode, getLastSelection } from "../../../utils/selection"; -import {CommonBlockAlignment} from "../../../nodes/_common"; +import {CommonBlockAlignment} from "lexical/nodes/common"; import {nodeHasAlignment} from "../../../utils/nodes"; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts index e0d1e7077fa..b36fd1c4f0c 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/block-formats.ts @@ -1,4 +1,4 @@ -import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "../../../nodes/callout"; +import {$createCalloutNode, $isCalloutNodeOfCategory, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorUiContext} from "../../framework/core"; import {$isParagraphNode, BaseSelection, LexicalNode} from "lexical"; diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index fd95f9f35d2..f9c029ff14c 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -2,27 +2,26 @@ import {EditorButtonDefinition} from "../../framework/buttons"; import linkIcon from "@icons/editor/link.svg"; import {EditorUiContext} from "../../framework/core"; import { - $createTextNode, $getRoot, $getSelection, $insertNodes, BaseSelection, - ElementNode, isCurrentlyReadOnlyMode + ElementNode } from "lexical"; import {$isLinkNode, LinkNode} from "@lexical/link"; import unlinkIcon from "@icons/editor/unlink.svg"; import imageIcon from "@icons/editor/image.svg"; -import {$isImageNode, ImageNode} from "../../../nodes/image"; +import {$isImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode"; import horizontalRuleIcon from "@icons/editor/horizontal-rule.svg"; -import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "../../../nodes/horizontal-rule"; +import {$createHorizontalRuleNode, $isHorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; import codeBlockIcon from "@icons/editor/code-block.svg"; -import {$isCodeBlockNode} from "../../../nodes/code-block"; +import {$isCodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; -import {$createDiagramNode, DiagramNode} from "../../../nodes/diagram"; +import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import detailsIcon from "@icons/editor/details.svg"; import mediaIcon from "@icons/editor/media.svg"; -import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details"; -import {$isMediaNode, MediaNode} from "../../../nodes/media"; +import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import { $getNodeFromSelection, $insertNewBlockNodeAtSelection, diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index 228566d442e..f00a08bb5f5 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -5,11 +5,10 @@ import { EditorSelectFormFieldDefinition } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; -import {$createNodeSelection, $createTextNode, $getSelection, $insertNodes, $setSelection} from "lexical"; -import {$isImageNode, ImageNode} from "../../../nodes/image"; -import {$createLinkNode, $isLinkNode, LinkNode} from "@lexical/link"; -import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "../../../nodes/media"; -import {$insertNodeToNearestRoot} from "@lexical/utils"; +import {$createNodeSelection, $getSelection, $insertNodes, $setSelection} from "lexical"; +import {$isImageNode, ImageNode} from "@lexical/rich-text/LexicalImageNode"; +import {LinkNode} from "@lexical/link"; +import {$createMediaNodeFromHtml, $createMediaNodeFromSrc, $isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; import {$getNodeFromSelection, getLastSelection} from "../../../utils/selection"; import {EditorFormModal} from "../../framework/modals"; import {EditorActionField} from "../../framework/blocks/action-field"; diff --git a/resources/js/wysiwyg/ui/defaults/forms/tables.ts b/resources/js/wysiwyg/ui/defaults/forms/tables.ts index 3cfe9592ccb..63fa24c800f 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/tables.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/tables.ts @@ -6,7 +6,7 @@ import { } from "../../framework/forms"; import {EditorUiContext} from "../../framework/core"; import {EditorFormModal} from "../../framework/modals"; -import {$getSelection, ElementFormatType} from "lexical"; +import {$getSelection} from "lexical"; import { $forEachTableCell, $getCellPaddingForTable, $getTableCellColumnWidth, @@ -16,7 +16,7 @@ import { } from "../../../utils/tables"; import {formatSizeValue} from "../../../utils/dom"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; -import {CommonBlockAlignment} from "../../../nodes/_common"; +import {CommonBlockAlignment} from "lexical/nodes/common"; const borderStyleInput: EditorSelectFormFieldDefinition = { label: 'Border style', diff --git a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts index 2e4f2939ca2..fa8ff48be56 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/node-resizer.ts @@ -1,10 +1,10 @@ import {BaseSelection, LexicalNode,} from "lexical"; import {MouseDragTracker, MouseDragTrackerDistance} from "./mouse-drag-tracker"; import {el} from "../../../utils/dom"; -import {$isImageNode} from "../../../nodes/image"; +import {$isImageNode} from "@lexical/rich-text/LexicalImageNode"; import {EditorUiContext} from "../core"; -import {NodeHasSize} from "../../../nodes/_common"; -import {$isMediaNode} from "../../../nodes/media"; +import {NodeHasSize} from "lexical/nodes/common"; +import {$isMediaNode} from "@lexical/rich-text/LexicalMediaNode"; function isNodeWithSize(node: LexicalNode): node is NodeHasSize&LexicalNode { return $isImageNode(node) || $isMediaNode(node); diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 7c0975da7e7..185cd5dccd0 100644 --- a/resources/js/wysiwyg/ui/framework/manager.ts +++ b/resources/js/wysiwyg/ui/framework/manager.ts @@ -1,7 +1,7 @@ import {EditorFormModal, EditorFormModalDefinition} from "./modals"; import {EditorContainerUiElement, EditorUiContext, EditorUiElement, EditorUiStateUpdate} from "./core"; import {EditorDecorator, EditorDecoratorAdapter} from "./decorator"; -import {$getSelection, BaseSelection, COMMAND_PRIORITY_LOW, LexicalEditor, SELECTION_CHANGE_COMMAND} from "lexical"; +import {BaseSelection, LexicalEditor} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; diff --git a/resources/js/wysiwyg/utils/diagrams.ts b/resources/js/wysiwyg/utils/diagrams.ts index fb5543005c2..ffd8e603b99 100644 --- a/resources/js/wysiwyg/utils/diagrams.ts +++ b/resources/js/wysiwyg/utils/diagrams.ts @@ -1,8 +1,8 @@ -import {$getSelection, $insertNodes, LexicalEditor, LexicalNode} from "lexical"; +import {$insertNodes, LexicalEditor, LexicalNode} from "lexical"; import {HttpError} from "../../services/http"; import {EditorUiContext} from "../ui/framework/core"; import * as DrawIO from "../../services/drawio"; -import {$createDiagramNode, DiagramNode} from "../nodes/diagram"; +import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import {ImageManager} from "../../components"; import {EditorImageData} from "./images"; import {$getNodeFromSelection, getLastSelection} from "./selection"; diff --git a/resources/js/wysiwyg/utils/formats.ts b/resources/js/wysiwyg/utils/formats.ts index 1be802ebf1c..a5f06f147d2 100644 --- a/resources/js/wysiwyg/utils/formats.ts +++ b/resources/js/wysiwyg/utils/formats.ts @@ -14,8 +14,8 @@ import { $toggleSelectionBlockNodeType, getLastSelection } from "./selection"; -import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "../nodes/code-block"; -import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "../nodes/callout"; +import {$createCodeBlockNode, $isCodeBlockNode, $openCodeEditorForNode, CodeBlockNode} from "@lexical/rich-text/LexicalCodeBlockNode"; +import {$createCalloutNode, $isCalloutNode, CalloutCategory} from "@lexical/rich-text/LexicalCalloutNode"; import {$isListNode, insertList, ListNode, ListType, removeList} from "@lexical/list"; import {$createLinkNode, $isLinkNode} from "@lexical/link"; import {$createHeadingNode, $isHeadingNode, HeadingTagType} from "@lexical/rich-text/LexicalHeadingNode"; diff --git a/resources/js/wysiwyg/utils/images.ts b/resources/js/wysiwyg/utils/images.ts index 2c13427d967..85bae18e5e5 100644 --- a/resources/js/wysiwyg/utils/images.ts +++ b/resources/js/wysiwyg/utils/images.ts @@ -1,5 +1,5 @@ import {ImageManager} from "../../components"; -import {$createImageNode} from "../nodes/image"; +import {$createImageNode} from "@lexical/rich-text/LexicalImageNode"; import {$createLinkNode, LinkNode} from "@lexical/link"; export type EditorImageData = { diff --git a/resources/js/wysiwyg/utils/nodes.ts b/resources/js/wysiwyg/utils/nodes.ts index 97634f96b4e..b5cc789550c 100644 --- a/resources/js/wysiwyg/utils/nodes.ts +++ b/resources/js/wysiwyg/utils/nodes.ts @@ -11,7 +11,7 @@ import { import {LexicalNodeMatcher} from "../nodes"; import {$generateNodesFromDOM} from "@lexical/html"; import {htmlToDom} from "./dom"; -import {NodeHasAlignment, NodeHasInset} from "../nodes/_common"; +import {NodeHasAlignment, NodeHasInset} from "lexical/nodes/common"; import {$findMatchingParent} from "@lexical/utils"; function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] { diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 02838eba034..28e729e92ae 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -16,7 +16,7 @@ import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; import {$getNearestNodeBlockParent, $getParentOfType, nodeHasAlignment} from "./nodes"; -import {CommonBlockAlignment} from "../nodes/_common"; +import {CommonBlockAlignment} from "lexical/nodes/common"; const lastSelectionByEditor = new WeakMap; From d00cf6e1bac6165354f3b8a3a440f948941fe011 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 4 Dec 2024 20:03:05 +0000 Subject: [PATCH 62/72] Lexical: Updated tests for node changes --- .../__tests__/unit/HTMLCopyAndPaste.test.ts | 4 +- .../core/__tests__/unit/LexicalEditor.test.ts | 18 +- .../__tests__/unit/LexicalEditorState.test.ts | 12 +- .../unit/LexicalSerialization.test.ts | 4 +- .../__tests__/unit/LexicalElementNode.test.ts | 2 - .../unit/LexicalParagraphNode.test.ts | 21 +- .../__tests__/unit/LexicalRootNode.test.ts | 2 - .../unit/LexicalHeadlessEditor.test.ts | 2 +- .../html/__tests__/unit/LexicalHtml.test.ts | 4 +- .../unit/LexicalListItemNode.test.ts | 554 ++++++++---------- .../__tests__/unit/LexicalListNode.test.ts | 19 - .../__tests__/unit/LexicalSelection.test.ts | 6 +- .../__tests__/unit/LexicalTableNode.test.ts | 5 +- .../unit/LexicalTableSelection.test.ts | 8 +- .../unit/LexicalEventHelpers.test.ts | 12 +- .../unit/LexicalUtilsSplitNode.test.ts | 4 +- ...exlcaiUtilsInsertNodeToNearestRoot.test.ts | 4 +- 17 files changed, 301 insertions(+), 380 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts index 534663a54b4..cdad252c92d 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/HTMLCopyAndPaste.test.ts @@ -82,12 +82,12 @@ describe('HTMLCopyAndPaste tests', () => { pastedHTML: ` 123
    456
    `, }, { - expectedHTML: `
    • done
    • todo
      • done
      • todo
    • todo
    `, + expectedHTML: `
    • done
    • todo
      • done
      • todo
    • todo
    `, name: 'google doc checklist', pastedHTML: `
    • checked

      done

    • unchecked

      todo

      • checked

        done

      • unchecked

        todo

    • unchecked

      todo

    `, }, { - expectedHTML: `

    checklist

    • done
    • todo
    `, + expectedHTML: `

    checklist

    • done
    • todo
    `, name: 'github checklist', pastedHTML: `

    checklist

    • done
    • todo
    `, }, diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts index 28a203100c4..5d763291972 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditor.test.ts @@ -974,7 +974,7 @@ describe('LexicalEditor tests', () => { editable ? 'editable' : 'non-editable' })`, async () => { const JSON_EDITOR_STATE = - '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'; + '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"123","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"textStyle":""}],"direction":null,"type":"root","version":1}}'; init(); const contentEditable = editor.getRootElement(); editor.setEditable(editable); @@ -1047,8 +1047,6 @@ describe('LexicalEditor tests', () => { __cachedText: null, __dir: null, __first: paragraphKey, - __format: 0, - __indent: 0, __key: 'root', __last: paragraphKey, __next: null, @@ -1059,10 +1057,11 @@ describe('LexicalEditor tests', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ + "__alignment": "", __dir: null, __first: textKey, - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: paragraphKey, __last: textKey, __next: null, @@ -1070,7 +1069,6 @@ describe('LexicalEditor tests', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); @@ -1129,8 +1127,6 @@ describe('LexicalEditor tests', () => { __cachedText: null, __dir: null, __first: paragraphKey, - __format: 0, - __indent: 0, __key: 'root', __last: paragraphKey, __next: null, @@ -1141,10 +1137,11 @@ describe('LexicalEditor tests', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ + "__alignment": "", __dir: null, __first: textKey, - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: paragraphKey, __last: textKey, __next: null, @@ -1152,7 +1149,6 @@ describe('LexicalEditor tests', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts index 38ecf03bce2..97b63450323 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalEditorState.test.ts @@ -54,8 +54,6 @@ describe('LexicalEditorState tests', () => { __cachedText: 'foo', __dir: null, __first: '1', - __format: 0, - __indent: 0, __key: 'root', __last: '1', __next: null, @@ -66,10 +64,11 @@ describe('LexicalEditorState tests', () => { __type: 'root', }); expect(paragraph).toEqual({ + "__alignment": "", __dir: null, __first: '2', - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: '1', __last: '2', __next: null, @@ -77,7 +76,6 @@ describe('LexicalEditorState tests', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); @@ -113,7 +111,7 @@ describe('LexicalEditorState tests', () => { }); expect(JSON.stringify(editor.getEditorState().toJSON())).toEqual( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Hello world","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"root","version":1}}`, ); }); @@ -140,8 +138,6 @@ describe('LexicalEditorState tests', () => { __cachedText: '', __dir: null, __first: null, - __format: 0, - __indent: 0, __key: 'root', __last: null, __next: null, diff --git a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts index 81eff674a99..e08547c13b6 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/unit/LexicalSerialization.test.ts @@ -107,7 +107,7 @@ describe('LexicalSerialization tests', () => { }); const stringifiedEditorState = JSON.stringify(editor.getEditorState()); - const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":null,"format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`; + const expectedStringifiedEditorState = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`; expect(stringifiedEditorState).toBe(expectedStringifiedEditorState); @@ -116,7 +116,7 @@ describe('LexicalSerialization tests', () => { const otherStringifiedEditorState = JSON.stringify(editorState); expect(otherStringifiedEditorState).toBe( - `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"quote","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"listitem","version":1,"value":4}],"direction":null,"format":"","indent":0,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1},{"children":[{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1},{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}`, + `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Welcome to the playground","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"In case you were wondering what the black box at the bottom is – it's the debug view, showing the current state of the editor. You can disable it by pressing on the settings control in the bottom-left of your screen and toggling the debug view setting.","type":"text","version":1}],"direction":null,"type":"quote","version":1,"id":"","alignment":"","inset":0},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"The playground is a demo environment built with ","type":"text","version":1},{"detail":0,"format":16,"mode":"normal","style":"","text":"@lexical/react","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":". Try typing in ","type":"text","version":1},{"detail":0,"format":1,"mode":"normal","style":"","text":"some text","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" with ","type":"text","version":1},{"detail":0,"format":2,"mode":"normal","style":"","text":"different","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":" formats.","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Make sure to check out the various plugins in the toolbar. You can also use #hashtags or @-mentions too!","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"If you'd like to find out more about Lexical, you can:","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Visit the ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical website","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://lexical.dev/"},{"detail":0,"format":0,"mode":"normal","style":"","text":" for documentation and more information.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Check out the code on our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"GitHub repository","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":2},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Playground code can be found ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"here","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://github.com/facebook/lexical/tree/main/packages/lexical-playground"},{"detail":0,"format":0,"mode":"normal","style":"","text":".","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":3},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Join our ","type":"text","version":1},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Discord Server","type":"text","version":1}],"direction":null,"type":"link","version":1,"rel":null,"target":null,"title":null,"url":"https://discord.com/invite/KmG4wQnnD9"},{"detail":0,"format":0,"mode":"normal","style":"","text":" and chat with the team.","type":"text","version":1}],"direction":null,"type":"listitem","version":1,"value":4}],"direction":null,"type":"list","version":1,"listType":"bullet","start":1,"tag":"ul","id":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lastly, we're constantly adding cool new features to this playground. So make sure you check back here when you next get a chance :).","type":"text","version":1}],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""},{"children":[{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0},{"children":[{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":2,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[],"direction":null,"type":"paragraph","version":1,"id":"","alignment":"","inset":0,"textStyle":""}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":0,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}}],"direction":null,"type":"root","version":1}}`, ); }); }); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts index fb5c98f8a6c..6e3a3861ae5 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalElementNode.test.ts @@ -84,8 +84,6 @@ describe('LexicalElementNode tests', () => { expect(node.exportJSON()).toStrictEqual({ children: [], direction: null, - format: '', - indent: 0, type: 'test_block', version: 1, }); diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts index 1f7c4cfc3a7..7bf485ca1d4 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalParagraphNode.test.ts @@ -48,11 +48,11 @@ describe('LexicalParagraphNode tests', () => { // logic is in place in the corresponding importJSON method // to accomodate these changes. expect(node.exportJSON()).toStrictEqual({ + alignment: '', children: [], direction: null, - format: '', - indent: 0, - textFormat: 0, + id: '', + inset: 0, textStyle: '', type: 'paragraph', version: 1, @@ -127,6 +127,21 @@ describe('LexicalParagraphNode tests', () => { }); }); + test('id is supported', async () => { + const {editor} = testEnv; + let paragraphNode: ParagraphNode; + + await editor.update(() => { + paragraphNode = new ParagraphNode(); + paragraphNode.setId('testid') + $getRoot().append(paragraphNode); + }); + + expect(testEnv.innerHTML).toBe( + '


    ', + ); + }); + test('$createParagraphNode()', async () => { const {editor} = testEnv; diff --git a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts index 123cb3375d6..7ef370f4ba7 100644 --- a/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts +++ b/resources/js/wysiwyg/lexical/core/nodes/__tests__/unit/LexicalRootNode.test.ts @@ -77,8 +77,6 @@ describe('LexicalRootNode tests', () => { expect(node.exportJSON()).toStrictEqual({ children: [], direction: null, - format: '', - indent: 0, type: 'root', version: 1, }); diff --git a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts index c4dedd47d13..122516d45b6 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -206,7 +206,7 @@ describe('LexicalHeadlessEditor', () => { cleanup(); expect(html).toBe( - '

    hello world

    ', + '

    hello world

    ', ); }); }); diff --git a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts index a4e2d231389..e5064121ab5 100644 --- a/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts +++ b/resources/js/wysiwyg/lexical/html/__tests__/unit/LexicalHtml.test.ts @@ -176,7 +176,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

    Hello world!

    ', + '

    Hello world!

    ', ); }); @@ -206,7 +206,7 @@ describe('HTML', () => { }); expect(html).toBe( - '

    Hello world!

    ', + '

    Hello world!

    ', ); }); }); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts index 523c7eb126e..567714bcd9f 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -62,7 +62,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( listItemNode.createDOM(editorConfig).outerHTML, html` -
  • +
  • `, ); @@ -90,7 +90,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
  • +
  • `, ); const newListItemNode = new ListItemNode(); @@ -106,7 +106,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
  • +
  • `, ); }); @@ -125,7 +125,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
  • +
  • `, ); const nestedListNode = new ListNode('bullet', 1); @@ -142,7 +142,7 @@ describe('LexicalListItemNode tests', () => { expectHtmlToBeEqual( domElement.outerHTML, html` -
  • +
  • `, ); }); @@ -486,53 +486,43 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A -
      • -
      -
    • -
    • - x -
    • -
    • - B -
    • -
    -
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • + B +
    • +
    `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A -
      • -
      -
    • -
    • - B -
    • -
    -
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + B +
    • +
    `, ); }); @@ -566,53 +556,43 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • - A -
    • -
    • - x -
    • -
    • -
        -
      • - B -
      • -
      -
    • -
    -
    +
      +
    • + A +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • - A -
    • -
    • -
        -
      • - B -
      • -
      -
    • -
    -
    +
      +
    • + A +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    `, ); }); @@ -650,57 +630,47 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A -
      • -
      -
    • -
    • - x -
    • -
    • -
        -
      • - B -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A -
      • -
      • - B -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A +
      • +
      • + B +
      • +
      +
    • +
    `, ); }); @@ -746,71 +716,61 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A1 -
      • -
      • -
          -
        • - A2 -
        • -
        -
      • -
      -
    • -
    • - x -
    • -
    • -
        -
      • - B -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • + B +
      • +
      +
    • +
    `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A1 -
      • -
      • -
          -
        • - A2 -
        • -
        -
      • -
      • - B -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      • + B +
      • +
      +
    • +
    `, ); }); @@ -856,71 +816,61 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A -
      • -
      -
    • -
    • - x -
    • -
    • -
        -
      • -
          -
        • - B1 -
        • -
        -
      • -
      • - B2 -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A -
      • -
      • -
          -
        • - B1 -
        • -
        -
      • -
      • - B2 -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A +
      • +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    `, ); }); @@ -974,81 +924,71 @@ describe('LexicalListItemNode tests', () => { }); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A1 -
      • -
      • -
          -
        • - A2 -
        • -
        -
      • -
      -
    • -
    • - x -
    • -
    • -
        -
      • -
          -
        • - B1 -
        • -
        -
      • -
      • - B2 -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        +
      • +
      +
    • +
    • + x +
    • +
    • +
        +
      • +
          +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    `, ); await editor.update(() => x.remove()); expectHtmlToBeEqual( - testEnv.outerHTML, + testEnv.innerHTML, html` -
    -
      -
    • -
        -
      • - A1 -
      • -
      • -
          -
        • - A2 -
        • -
        • - B1 -
        • -
        -
      • -
      • - B2 -
      • -
      -
    • -
    -
    +
      +
    • +
        +
      • + A1 +
      • +
      • +
          +
        • + A2 +
        • +
        • + B1 +
        • +
        +
      • +
      • + B2 +
      • +
      +
    • +
    `, ); }); diff --git a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts index 497e096b1c2..8c7729dbff1 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListNode.test.ts @@ -294,24 +294,5 @@ describe('LexicalListNode tests', () => { expect(bulletList.__listType).toBe('bullet'); }); }); - - test('ListNode.clone() without list type (backward compatibility)', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const olNode = ListNode.clone({ - __key: '1', - __start: 1, - __tag: 'ol', - } as unknown as ListNode); - const ulNode = ListNode.clone({ - __key: '1', - __start: 1, - __tag: 'ul', - } as unknown as ListNode); - expect(olNode.__listType).toBe('number'); - expect(ulNode.__listType).toBe('bullet'); - }); - }); }); }); diff --git a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts index 466be7498de..cc09d1735cc 100644 --- a/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts +++ b/resources/js/wysiwyg/lexical/selection/__tests__/unit/LexicalSelection.test.ts @@ -2605,7 +2605,7 @@ describe('LexicalSelection tests', () => { return $createHeadingNode('h1'); }); expect(JSON.stringify(testEditor._pendingEditorState?.toJSON())).toBe( - '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1}],"direction":null,"format":"","indent":0,"type":"tablerow","version":1}],"direction":null,"format":"","indent":0,"type":"table","version":1},{"children":[],"direction":null,"format":"","indent":0,"type":"heading","version":1,"tag":"h1"}],"direction":null,"format":"","indent":0,"type":"root","version":1}}', + '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1},{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[{"children":[{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":3,"rowSpan":1,"styles":{},"alignment":""},{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"","type":"text","version":1}],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"}],"direction":null,"type":"tablecell","version":1,"backgroundColor":null,"colSpan":1,"headerState":1,"rowSpan":1,"styles":{},"alignment":""}],"direction":null,"type":"tablerow","version":1,"styles":{},"height":0}],"direction":null,"type":"table","version":1,"id":"","alignment":"","inset":0,"colWidths":[],"styles":{}},{"children":[],"direction":null,"type":"heading","version":1,"id":"","alignment":"","inset":0,"tag":"h1"}],"direction":null,"type":"root","version":1}}', ); }); }); @@ -2695,7 +2695,7 @@ describe('LexicalSelection tests', () => { }); }); expect(element.innerHTML).toStrictEqual( - `

    1

    1.1

    `, + `

    1

    • 1.1

    `, ); }); @@ -2734,7 +2734,7 @@ describe('LexicalSelection tests', () => { }); }); expect(element.innerHTML).toStrictEqual( - `

    1.1

    `, + `
    • 1.1

    `, ); }); }); diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts index 6848e55325d..2879decdad7 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableNode.test.ts @@ -113,9 +113,8 @@ describe('LexicalTableNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); // Make sure paragraph is inserted inside empty cells - const emptyCell = '


    '; expect(testEnv.innerHTML).toBe( - `${emptyCell}

    Hello there

    General Kenobi!

    Lexical is nice

    `, + `

    Hello there

    General Kenobi!

    Lexical is nice


    `, ); }); @@ -136,7 +135,7 @@ describe('LexicalTableNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); expect(testEnv.innerHTML).toBe( - `

    Surface

    MWP_WORK_LS_COMPOSER

    77349

    Lexical

    XDS_RICH_TEXT_AREA

    sdvd sdfvsfs

    `, + `

    Surface

    MWP_WORK_LS_COMPOSER

    77349

    Lexical

    XDS_RICH_TEXT_AREA

    sdvd sdfvsfs

    `, ); }); }, diff --git a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts index d5b85ccaaa0..1548216cf1a 100644 --- a/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts +++ b/resources/js/wysiwyg/lexical/table/__tests__/unit/LexicalTableSelection.test.ts @@ -101,8 +101,6 @@ describe('table selection', () => { __cachedText: null, __dir: null, __first: paragraphKey, - __format: 0, - __indent: 0, __key: 'root', __last: paragraphKey, __next: null, @@ -113,10 +111,11 @@ describe('table selection', () => { __type: 'root', }); expect(parsedParagraph).toEqual({ + __alignment: "", __dir: null, __first: textKey, - __format: 0, - __indent: 0, + __id: '', + __inset: 0, __key: paragraphKey, __last: textKey, __next: null, @@ -124,7 +123,6 @@ describe('table selection', () => { __prev: null, __size: 1, __style: '', - __textFormat: 0, __textStyle: '', __type: 'paragraph', }); diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts index d76937ed606..cae4f1aae68 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalEventHelpers.test.ts @@ -176,7 +176,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
    • Other side
    • I must have called
    ', + '
    • Other side
    • I must have called
    ', inputs: [ pasteHTML( `
    • Other side
    • I must have called
    `, @@ -186,7 +186,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
    1. To tell you
    2. I’m sorry
    ', + '
    1. To tell you
    2. I’m sorry
    ', inputs: [ pasteHTML( `
    1. To tell you
    2. I’m sorry
    `, @@ -266,7 +266,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
    • Hello
    • from the other
    • side
    ', + '
    • Hello
    • from the other
    • side
    ', inputs: [ pasteHTML( `
    • Hello
    • from the other
    • side
    `, @@ -276,7 +276,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
    • Hello
    • from the other
    • side
    ', + '
    • Hello
    • from the other
    • side
    ', inputs: [ pasteHTML( `
    • Hello
    • from the other
    • side
    `, @@ -611,7 +611,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
    1. 1
      2

    2. 3
    ', + '
    1. 1
      2

    2. 3
    ', inputs: [ pasteHTML('
    1. 1
      2
    2. 3
    '), ], @@ -647,7 +647,7 @@ describe('LexicalEventHelpers', () => { }, { expectedHTML: - '
    1. 1

    2. 3
    ', + '
    1. 1

    2. 3
    ', inputs: [pasteHTML('
    1. 1

    2. 3
    ')], name: 'only br in a li', }, diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts index a70200d6349..54cd8b54f1e 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexicalUtilsSplitNode.test.ts @@ -82,10 +82,10 @@ describe('LexicalUtils#splitNode', () => { expectedHtml: '
      ' + '
    • Before
    • ' + - '
      • Hello
    • ' + + '
      • Hello
    • ' + '
    ' + '
      ' + - '
      • world
    • ' + + '
      • world
    • ' + '
    • After
    • ' + '
    ', initialHtml: diff --git a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts index fb04e628413..8c31496de5a 100644 --- a/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts +++ b/resources/js/wysiwyg/lexical/utils/__tests__/unit/LexlcaiUtilsInsertNodeToNearestRoot.test.ts @@ -56,11 +56,11 @@ describe('LexicalUtils#insertNodeToNearestRoot', () => { expectedHtml: '
      ' + '
    • Before
    • ' + - '
      • Hello
    • ' + + '
      • Hello
    • ' + '
    ' + '' + '
      ' + - '
      • world
    • ' + + '
      • world
    • ' + '
    • After
    • ' + '
    ', initialHtml: From 55d074f1a5922fa966a428d9f03e0a1e405e33aa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Dec 2024 11:32:15 +0000 Subject: [PATCH 63/72] Attachment API: Fixed error when name not provided in update Fixes #5353 --- app/Uploads/AttachmentService.php | 8 +++++--- tests/Api/AttachmentsApiTest.php | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 033f2334104..dabd537292f 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -116,16 +116,18 @@ public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId */ public function updateFile(Attachment $attachment, array $requestData): Attachment { - $attachment->name = $requestData['name']; - $link = trim($requestData['link'] ?? ''); + if (isset($requestData['name'])) { + $attachment->name = $requestData['name']; + } + $link = trim($requestData['link'] ?? ''); if (!empty($link)) { if (!$attachment->external) { $this->deleteFileInStorage($attachment); $attachment->external = true; $attachment->extension = ''; } - $attachment->path = $requestData['link']; + $attachment->path = $link; } $attachment->save(); diff --git a/tests/Api/AttachmentsApiTest.php b/tests/Api/AttachmentsApiTest.php index b03f280ac67..b2346587924 100644 --- a/tests/Api/AttachmentsApiTest.php +++ b/tests/Api/AttachmentsApiTest.php @@ -12,7 +12,7 @@ class AttachmentsApiTest extends TestCase { use TestsApi; - protected $baseEndpoint = '/api/attachments'; + protected string $baseEndpoint = '/api/attachments'; public function test_index_endpoint_returns_expected_book() { @@ -302,6 +302,23 @@ public function test_update_link_attachment_to_file() } public function test_update_file_attachment_to_link() + { + $this->actingAsApiAdmin(); + $page = $this->entities->page(); + $attachment = $this->createAttachmentForPage($page); + + $resp = $this->putJson("{$this->baseEndpoint}/{$attachment->id}", [ + 'link' => 'https://example.com/donkey', + ]); + + $resp->assertStatus(200); + $this->assertDatabaseHas('attachments', [ + 'id' => $attachment->id, + 'path' => 'https://example.com/donkey', + ]); + } + + public function test_update_does_not_require_name() { $this->actingAsApiAdmin(); $page = $this->entities->page(); From 617b2edea0bfe8ebb5556aca6b0b6aa371232cca Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Dec 2024 13:07:39 +0000 Subject: [PATCH 64/72] JS: Updated packages, fixed lint issue Left eslint as old due to eslint-config-airbnb-base not yet being comptible. Some SASS deprecations to solve. --- dev/docs/development.md | 2 +- package-lock.json | 812 +++++++++++++++++++++++-------- package.json | 40 +- resources/js/components/index.ts | 2 +- 4 files changed, 642 insertions(+), 214 deletions(-) diff --git a/dev/docs/development.md b/dev/docs/development.md index 0324140f8bd..ea3e692a198 100644 --- a/dev/docs/development.md +++ b/dev/docs/development.md @@ -3,7 +3,7 @@ All development on BookStack is currently done on the `development` branch. When it's time for a release the `development` branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements: -* [Node.js](https://nodejs.org/en/) v18.0+ +* [Node.js](https://nodejs.org/en/) v20.0+ ## Building CSS & JavaScript Assets diff --git a/package-lock.json b/package-lock.json index 68ec85fff8b..1912106c2b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,46 +4,47 @@ "requires": true, "packages": { "": { + "name": "bookstack", "dependencies": { - "@codemirror/commands": "^6.3.2", - "@codemirror/lang-css": "^6.2.1", - "@codemirror/lang-html": "^6.4.7", - "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.2.3", + "@codemirror/lang-markdown": "^6.3.1", "@codemirror/lang-php": "^6.0.1", - "@codemirror/lang-xml": "^6.0.2", - "@codemirror/language": "^6.9.3", - "@codemirror/legacy-modes": "^6.3.3", - "@codemirror/state": "^6.3.3", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.10.6", + "@codemirror/legacy-modes": "^6.4.2", + "@codemirror/state": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.22.2", - "@lezer/highlight": "^1.2.0", + "@codemirror/view": "^6.35.2", + "@lezer/highlight": "^1.2.1", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^29.5.13", + "@types/jest": "^29.5.14", "codemirror": "^6.0.1", "idb-keyval": "^6.2.1", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.5.1", - "sortablejs": "^1.15.1" + "snabbdom": "^3.6.2", + "sortablejs": "^1.15.6" }, "devDependencies": { - "@lezer/generator": "^1.5.1", + "@lezer/generator": "^1.7.2", "chokidar-cli": "^3.0", - "esbuild": "^0.23.0", - "eslint": "^8.55.0", + "esbuild": "^0.24.0", + "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.29.0", + "eslint-plugin-import": "^2.31.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", - "sass": "^1.69.5", + "sass": "^1.82.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "5.6.*" + "typescript": "5.7.*" } }, "node_modules/@ampproject/remapping": { @@ -627,9 +628,10 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.2.tgz", - "integrity": "sha512-Fq7eWOl1Rcbrfn6jD8FPCj9Auaxdm5nIK5RYOeW7ughnd/rY5AmPg6b+CfsG39ZHdwiwe8lde3q8uR7CF5S0yQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", @@ -638,9 +640,10 @@ } }, "node_modules/@codemirror/lang-css": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.0.tgz", - "integrity": "sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -689,9 +692,10 @@ } }, "node_modules/@codemirror/lang-markdown": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.2.5.tgz", - "integrity": "sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.1.tgz", + "integrity": "sha512-y3sSPuQjBKZQbQwe3ZJKrSW6Silyl9PnrU/Mf0m2OQgIlPoSYTtOvEL7xs94SVMkb8f4x+SQFnzXPdX4Wk2lsg==", + "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", @@ -728,9 +732,10 @@ } }, "node_modules/@codemirror/language": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", - "integrity": "sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==", + "version": "6.10.6", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.6.tgz", + "integrity": "sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -741,9 +746,10 @@ } }, "node_modules/@codemirror/legacy-modes": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.1.tgz", - "integrity": "sha512-vdg3XY7OAs5uLDx2Iw+cGfnwtd7kM+Et/eMsqAGTfT/JKiVBQZXosTzjEbWAi/FrY6DcQIz8mQjBozFHZEUWQA==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.2.tgz", + "integrity": "sha512-HsvWu08gOIIk303eZQCal4H4t65O/qp1V4ul4zVa3MHK5FJ0gz3qz3O55FIkm+aQUcshUOjBx38t2hPiJwW5/g==", + "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0" } @@ -769,9 +775,13 @@ } }, "node_modules/@codemirror/state": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", - "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } }, "node_modules/@codemirror/theme-one-dark": { "version": "6.1.2", @@ -785,9 +795,10 @@ } }, "node_modules/@codemirror/view": { - "version": "6.34.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.34.1.tgz", - "integrity": "sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==", + "version": "6.35.2", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.35.2.tgz", + "integrity": "sha512-u04R04XFCYCNaHoNRr37WUUAfnxKPwPdqV+370NiO6i85qB1J/qCD/WbbMJsyJfRWhXIJXAe2BG/oTzAggqv4A==", + "license": "MIT", "dependencies": { "@codemirror/state": "^6.4.0", "style-mod": "^4.1.0", @@ -817,13 +828,14 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", + "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "aix" @@ -833,13 +845,14 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", + "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -849,13 +862,14 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", + "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -865,13 +879,14 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", + "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -881,13 +896,14 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", + "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -897,13 +913,14 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", + "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -913,13 +930,14 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", + "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -929,13 +947,14 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", + "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -945,13 +964,14 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", + "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -961,13 +981,14 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", + "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -977,13 +998,14 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", + "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -993,13 +1015,14 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", + "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1009,13 +1032,14 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", + "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1025,13 +1049,14 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", + "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1041,13 +1066,14 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", + "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1057,13 +1083,14 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", + "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1073,13 +1100,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", + "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -1089,13 +1117,14 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", + "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" @@ -1105,13 +1134,14 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", + "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1121,13 +1151,14 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", + "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" @@ -1137,13 +1168,14 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", + "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" @@ -1153,13 +1185,14 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", + "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1169,13 +1202,14 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", + "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1185,13 +1219,14 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", + "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1216,10 +1251,11 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", "dev": true, + "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } @@ -1229,6 +1265,7 @@ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, + "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1252,6 +1289,7 @@ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, + "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1262,6 +1300,7 @@ "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dev": true, + "license": "Apache-2.0", "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1289,7 +1328,8 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -1738,10 +1778,11 @@ } }, "node_modules/@lezer/generator": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.1.tgz", - "integrity": "sha512-MgPJN9Si+ccxzXl3OAmCeZuUKw4XiPl4y664FX/hnnyG9CTqUPq65N3/VGPA2jD23D7QgMTtNqflta+cPN+5mQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/generator/-/generator-1.7.2.tgz", + "integrity": "sha512-CwgULPOPPmH54tv4gki18bElLCdJ1+FBC+nGVSVD08vFWDsMjS7KEjNTph9JOypDnet90ujN3LzQiW3CyVODNQ==", "dev": true, + "license": "MIT", "dependencies": { "@lezer/common": "^1.1.0", "@lezer/lr": "^1.3.0" @@ -1825,11 +1866,18 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.0.tgz", + "integrity": "sha512-0YSzy7M9mBiK+h1m33rD8vZOfaO8leG6CY3+Q+1Lig86snkc8OAHQVAdndmnXMWJlVIH6S7fSZVVcjLcq6OH1A==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -1843,6 +1891,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 8" } @@ -1852,6 +1901,7 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -1860,6 +1910,316 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2009,9 +2369,10 @@ } }, "node_modules/@types/jest": { - "version": "29.5.13", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", - "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "license": "MIT", "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" @@ -2067,10 +2428,11 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", + "integrity": "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==", + "dev": true, + "license": "ISC" }, "node_modules/abab": { "version": "2.0.6", @@ -2080,10 +2442,11 @@ "dev": true }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -2106,6 +2469,7 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, + "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -2139,6 +2503,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2857,10 +3222,11 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -3063,6 +3429,20 @@ "node": ">=0.4.0" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3094,6 +3474,7 @@ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" }, @@ -3307,11 +3688,12 @@ } }, "node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", + "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -3319,30 +3701,30 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "@esbuild/aix-ppc64": "0.24.0", + "@esbuild/android-arm": "0.24.0", + "@esbuild/android-arm64": "0.24.0", + "@esbuild/android-x64": "0.24.0", + "@esbuild/darwin-arm64": "0.24.0", + "@esbuild/darwin-x64": "0.24.0", + "@esbuild/freebsd-arm64": "0.24.0", + "@esbuild/freebsd-x64": "0.24.0", + "@esbuild/linux-arm": "0.24.0", + "@esbuild/linux-arm64": "0.24.0", + "@esbuild/linux-ia32": "0.24.0", + "@esbuild/linux-loong64": "0.24.0", + "@esbuild/linux-mips64el": "0.24.0", + "@esbuild/linux-ppc64": "0.24.0", + "@esbuild/linux-riscv64": "0.24.0", + "@esbuild/linux-s390x": "0.24.0", + "@esbuild/linux-x64": "0.24.0", + "@esbuild/netbsd-x64": "0.24.0", + "@esbuild/openbsd-arm64": "0.24.0", + "@esbuild/openbsd-x64": "0.24.0", + "@esbuild/sunos-x64": "0.24.0", + "@esbuild/win32-arm64": "0.24.0", + "@esbuild/win32-ia32": "0.24.0", + "@esbuild/win32-x64": "0.24.0" } }, "node_modules/escalade": { @@ -3391,7 +3773,9 @@ "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3508,10 +3892,11 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", - "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, + "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -3521,7 +3906,7 @@ "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.9.0", + "eslint-module-utils": "^2.12.0", "hasown": "^2.0.2", "is-core-module": "^2.15.1", "is-glob": "^4.0.3", @@ -3530,13 +3915,14 @@ "object.groupby": "^1.0.3", "object.values": "^1.2.0", "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", "tsconfig-paths": "^3.15.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "node_modules/eslint-plugin-import/node_modules/debug": { @@ -3565,6 +3951,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -3605,6 +3992,7 @@ "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -3647,6 +4035,7 @@ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -3723,7 +4112,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", @@ -3742,6 +4132,7 @@ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, + "license": "ISC", "dependencies": { "reusify": "^1.0.4" } @@ -3760,6 +4151,7 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, + "license": "MIT", "dependencies": { "flat-cache": "^3.0.4" }, @@ -3829,6 +4221,7 @@ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, + "license": "MIT", "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -3839,10 +4232,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true, + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.3", @@ -4036,6 +4430,7 @@ "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -4083,7 +4478,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/has-bigints": { "version": "1.0.2", @@ -4247,21 +4643,24 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, + "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/immutable": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", - "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", - "dev": true + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", + "dev": true, + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, + "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -4530,6 +4929,7 @@ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -5434,6 +5834,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -5502,7 +5903,8 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-parse-better-errors": { "version": "1.0.2", @@ -5520,7 +5922,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5545,6 +5948,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, + "license": "MIT", "dependencies": { "json-buffer": "3.0.1" } @@ -5888,6 +6292,14 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -5997,10 +6409,11 @@ "dev": true }, "node_modules/npm-run-all/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, + "license": "MIT", "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -6304,6 +6717,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "license": "MIT", "dependencies": { "callsites": "^3.0.0" }, @@ -6614,7 +7028,8 @@ "type": "consulting", "url": "https://feross.org/support" } - ] + ], + "license": "MIT" }, "node_modules/react-is": { "version": "18.3.1", @@ -6729,6 +7144,7 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -6747,6 +7163,7 @@ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, + "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -6758,6 +7175,7 @@ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -6787,6 +7205,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -6833,13 +7252,14 @@ "dev": true }, "node_modules/sass": { - "version": "1.79.3", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.3.tgz", - "integrity": "sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.82.0.tgz", + "integrity": "sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==", "dev": true, + "license": "MIT", "dependencies": { "chokidar": "^4.0.0", - "immutable": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -6847,6 +7267,9 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/sass/node_modules/chokidar": { @@ -7013,9 +7436,10 @@ } }, "node_modules/sortablejs": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.3.tgz", - "integrity": "sha512-zdK3/kwwAK1cJgy1rwl1YtNTbRmc8qW/+vgXf75A7NHag5of4pyI6uK86ktmQETyWRH7IGaE73uZOOBcGxgqZg==" + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" }, "node_modules/source-map": { "version": "0.6.1", @@ -7311,7 +7735,8 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", @@ -7528,6 +7953,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -7609,10 +8035,11 @@ } }, "node_modules/typescript": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", - "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, + "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7690,6 +8117,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index 42531194ec3..08af25d140f 100644 --- a/package.json +++ b/package.json @@ -19,45 +19,45 @@ "test": "jest" }, "devDependencies": { - "@lezer/generator": "^1.5.1", + "@lezer/generator": "^1.7.2", "chokidar-cli": "^3.0", - "esbuild": "^0.23.0", - "eslint": "^8.55.0", + "esbuild": "^0.24.0", + "eslint": "^8.57.1", "eslint-config-airbnb-base": "^15.0.0", - "eslint-plugin-import": "^2.29.0", + "eslint-plugin-import": "^2.31.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "livereload": "^0.9.3", "npm-run-all": "^4.1.5", - "sass": "^1.69.5", + "sass": "^1.82.0", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "5.6.*" + "typescript": "5.7.*" }, "dependencies": { - "@codemirror/commands": "^6.3.2", - "@codemirror/lang-css": "^6.2.1", - "@codemirror/lang-html": "^6.4.7", - "@codemirror/lang-javascript": "^6.2.1", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", - "@codemirror/lang-markdown": "^6.2.3", + "@codemirror/lang-markdown": "^6.3.1", "@codemirror/lang-php": "^6.0.1", - "@codemirror/lang-xml": "^6.0.2", - "@codemirror/language": "^6.9.3", - "@codemirror/legacy-modes": "^6.3.3", - "@codemirror/state": "^6.3.3", + "@codemirror/lang-xml": "^6.1.0", + "@codemirror/language": "^6.10.6", + "@codemirror/legacy-modes": "^6.4.2", + "@codemirror/state": "^6.5.0", "@codemirror/theme-one-dark": "^6.1.2", - "@codemirror/view": "^6.22.2", - "@lezer/highlight": "^1.2.0", + "@codemirror/view": "^6.35.2", + "@lezer/highlight": "^1.2.1", "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", - "@types/jest": "^29.5.13", + "@types/jest": "^29.5.14", "codemirror": "^6.0.1", "idb-keyval": "^6.2.1", "markdown-it": "^14.1.0", "markdown-it-task-lists": "^2.1.1", - "snabbdom": "^3.5.1", - "sortablejs": "^1.15.1" + "snabbdom": "^3.6.2", + "sortablejs": "^1.15.6" }, "eslintConfig": { "root": true, diff --git a/resources/js/components/index.ts b/resources/js/components/index.ts index 24e60bd97f2..12c991a51d8 100644 --- a/resources/js/components/index.ts +++ b/resources/js/components/index.ts @@ -30,7 +30,7 @@ export {HeaderMobileToggle} from './header-mobile-toggle'; export {ImageManager} from './image-manager'; export {ImagePicker} from './image-picker'; export {ListSortControl} from './list-sort-control'; -export {LoadingButton} from './loading-button.ts'; +export {LoadingButton} from './loading-button'; export {MarkdownEditor} from './markdown-editor'; export {NewUserPassword} from './new-user-password'; export {Notification} from './notification'; From 8ec26e80834841dd66ae6123dad8855d8509120f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 9 Dec 2024 13:25:35 +0000 Subject: [PATCH 65/72] SASS: Updated to use modules and address deprecations Changes the name of our spacing variables due to the prefixing -/_ meaning private in the use of new "use" rather than include. All now modular too, so all variables/mixins are accessed via their package. Also renamed variables file to vars for simpler/cleaner access/writing. eg. '$-m' is now 'vars.$m' --- resources/sass/_blocks.scss | 97 ++++----- resources/sass/_buttons.scss | 33 ++-- resources/sass/_codemirror.scss | 11 +- resources/sass/_colors.scss | 8 +- resources/sass/_components.scss | 187 +++++++++--------- resources/sass/_content.scss | 65 +++--- resources/sass/_editor.scss | 27 +-- resources/sass/_forms.scss | 123 ++++++------ resources/sass/_header.scss | 97 ++++----- resources/sass/_html.scss | 7 +- resources/sass/_layout.scss | 87 ++++---- resources/sass/_lists.scss | 173 ++++++++-------- resources/sass/_pages.scss | 65 +++--- resources/sass/_spacing.scss | 6 +- resources/sass/_tables.scss | 21 +- resources/sass/_text.scss | 67 ++++--- resources/sass/_tinymce.scss | 19 +- .../sass/{_variables.scss => _vars.scss} | 36 ++-- resources/sass/export-styles.scss | 13 +- resources/sass/styles.scss | 102 +++++----- 20 files changed, 649 insertions(+), 595 deletions(-) rename resources/sass/{_variables.scss => _vars.scss} (85%) diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index 9e96e920b8d..8c248caee14 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -1,14 +1,17 @@ +@use "mixins"; +@use "vars"; + /** * Card-style blocks */ .card { - @include lightDark(background-color, #FFF, #222); - box-shadow: $bs-card; + @include mixins.lightDark(background-color, #FFF, #222); + box-shadow: vars.$bs-card; border-radius: 3px; break-inside: avoid; .body, p.empty-text { - padding-block: $-m; + padding-block: vars.$m; } a, p { word-wrap: break-word; @@ -17,9 +20,9 @@ } .card-title { - padding: $-m $-m $-xs; + padding: vars.$m vars.$m vars.$xs; margin: 0; - font-size: $fs-m; + font-size: vars.$fs-m; color: #222; fill: #222; font-weight: 400; @@ -29,21 +32,21 @@ } .card-footer-link, button.card-footer-link { display: block; - padding: $-s $-m; + padding: vars.$s vars.$m; line-height: 1; border-top: 1px solid; width: 100%; text-align: left; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); border-radius: 0 0 3px 3px; font-size: 0.9em; - margin-top: $-xs; + margin-top: vars.$xs; &:hover { text-decoration: none; - @include lightDark(background-color, #f2f2f2, #2d2d2d); + @include mixins.lightDark(background-color, #f2f2f2, #2d2d2d); } &:focus { - @include lightDark(background-color, #eee, #222); + @include mixins.lightDark(background-color, #eee, #222); outline: 1px dotted #666; outline-offset: -2px; } @@ -51,17 +54,17 @@ .card.border-card { border: 1px solid; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); } .card.drag-card { border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(border-color, #ddd, #000); + @include mixins.lightDark(background-color, #fff, #333); border-radius: 4px; display: flex; - padding: 0 0 0 ($-s + 28px); - margin: $-s 0; + padding: 0 0 0 (vars.$s + 28px); + margin: vars.$s 0; position: relative; .drag-card-action { cursor: pointer; @@ -73,30 +76,30 @@ justify-content: center; width: 28px; flex-grow: 0; - padding: 0 $-xs; + padding: 0 vars.$xs; &:hover { - @include lightDark(background-color, #eee, #2d2d2d); + @include mixins.lightDark(background-color, #eee, #2d2d2d); } .svg-icon { margin-inline-end: 0px; } } .outline input { - margin: $-s 0; + margin: vars.$s 0; width: 100%; } .outline { position: relative; } .handle { - @include lightDark(background-color, #eee, #2d2d2d); + @include mixins.lightDark(background-color, #eee, #2d2d2d); left: 0; position: absolute; top: 0; bottom: 0; } > div { - padding: 0 $-s; + padding: 0 vars.$s; max-width: 80%; flex: 1; } @@ -106,17 +109,17 @@ display: flex; flex-direction: column; border: 1px solid #ddd; - @include lightDark(border-color, #ddd, #000); - margin-bottom: $-l; + @include mixins.lightDark(border-color, #ddd, #000); + margin-bottom: vars.$l; border-radius: 4px; overflow: hidden; min-width: 100px; - color: $text-dark; + color: vars.$text-dark; transition: border-color ease-in-out 120ms, box-shadow ease-in-out 120ms; &:hover { - color: $text-dark; + color: vars.$text-dark; text-decoration: none; - @include lightDark(box-shadow, $bs-card, $bs-card-dark); + @include mixins.lightDark(box-shadow, vars.$bs-card, vars.$bs-card-dark); } h2 { width: 100%; @@ -134,7 +137,7 @@ border-bottom-width: 2px; } .grid-card-content, .grid-card-footer { - padding: $-l; + padding: vars.$l; } .grid-card-content + .grid-card-footer { padding-top: 0; @@ -149,10 +152,10 @@ } .content-wrap.card { - padding: $-m $-xxl; + padding: vars.$m vars.$xxl; margin-inline-start: auto; margin-inline-end: auto; - margin-bottom: $-l; + margin-bottom: vars.$l; overflow: initial; min-height: 60vh; border-radius: 8px; @@ -163,26 +166,26 @@ width: 100%; } } -@include smaller-than($xxl) { +@include mixins.smaller-than(vars.$bp-xxl) { .content-wrap.card { - padding: $-m $-xl; + padding: vars.$m vars.$xl; } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .content-wrap.card { - padding: $-m $-l; + padding: vars.$m vars.$l; } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .content-wrap.card { - padding: $-m $-m; + padding: vars.$m vars.$m; } } .sub-card { box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); border: 1.5px solid; - @include lightDark(border-color, #E2E2E2, #444); + @include mixins.lightDark(border-color, #E2E2E2, #444); border-radius: 4px; } @@ -194,7 +197,7 @@ } .fade-in-when-active { - @include lightDark(opacity, 0.6, 0.7); + @include mixins.lightDark(opacity, 0.6, 0.7); transition: opacity ease-in-out 120ms; &:hover, &:focus-within { opacity: 1 !important; @@ -209,29 +212,29 @@ */ .tag-item { display: inline-flex; - margin-bottom: $-xs; - margin-inline-end: $-xs; + margin-bottom: vars.$xs; + margin-inline-end: vars.$xs; border-radius: 4px; border: 1px solid; overflow: hidden; font-size: 0.85em; - @include lightDark(border-color, #CCC, #666); + @include mixins.lightDark(border-color, #CCC, #666); a, span, a:hover, a:active { padding: 4px 8px; - @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8)); + @include mixins.lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8)); transition: background-color ease-in-out 80ms; text-decoration: none; } a:hover { - @include lightDark(background-color, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3)); + @include mixins.lightDark(background-color, rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.3)); } svg { - @include lightDark(fill, rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5)); + @include mixins.lightDark(fill, rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.5)); } .tag-value { border-inline-start: 1px solid; - @include lightDark(border-color, #DDD, #666); - @include lightDark(background-color, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.2)) + @include mixins.lightDark(border-color, #DDD, #666); + @include mixins.lightDark(background-color, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.2)) } } @@ -253,7 +256,7 @@ .api-method { font-size: 0.75rem; background-color: #888; - padding: $-xs; + padding: vars.$xs; line-height: 1.3; opacity: 0.7; vertical-align: top; @@ -271,7 +274,7 @@ .sticky-sidebar { position: sticky; - top: $-m; - max-height: calc(100vh - #{$-m}); + top: vars.$m; + max-height: calc(100vh - #{vars.$m}); overflow-y: auto; } diff --git a/resources/sass/_buttons.scss b/resources/sass/_buttons.scss index e629e772662..29deda3a08a 100644 --- a/resources/sass/_buttons.scss +++ b/resources/sass/_buttons.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + button { background-color: transparent; border: 0; @@ -8,9 +11,9 @@ button { text-decoration: none; font-size: 0.85rem; line-height: 1.4em; - padding: $-xs*1.3 $-m; - margin-top: $-xs; - margin-bottom: $-xs; + padding: vars.$xs*1.3 vars.$m; + margin-top: vars.$xs; + margin-bottom: vars.$xs; display: inline-block; font-weight: 400; outline: 0; @@ -30,12 +33,12 @@ button { color: #FFFFFF; } &:hover { - @include lightDark(box-shadow, $bs-light, $bs-dark); + @include mixins.lightDark(box-shadow, vars.$bs-light, vars.$bs-dark); filter: brightness(110%); } &:focus { outline: 1px dotted currentColor; - outline-offset: -$-xs; + outline-offset: -(vars.$xs); box-shadow: none; filter: brightness(90%); } @@ -46,16 +49,16 @@ button { .button.outline { background-color: transparent; - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(color, #666, #AAA); fill: currentColor; border: 1px solid; - @include lightDark(border-color, #CCC, #666); + @include mixins.lightDark(border-color, #CCC, #666); &:hover, &:focus, &:active { - @include lightDark(color, #444, #BBB); + @include mixins.lightDark(color, #444, #BBB); border: 1px solid #CCC; box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); background-color: #F2F2F2; - @include lightDark(background-color, #f8f8f8, #444); + @include mixins.lightDark(background-color, #f8f8f8, #444); filter: none; } &:active { @@ -67,12 +70,12 @@ button { } .button + .button { - margin-inline-start: $-s; + margin-inline-start: vars.$s; } .button.small { font-size: 0.75rem; - padding: $-xs*1.2 $-s; + padding: vars.$xs*1.2 vars.$s; } .text-button { @@ -119,22 +122,22 @@ button { .icon-button:hover { background-color: rgba(0, 0, 0, 0.05); border-radius: 4px; - @include lightDark(border-color, #DDD, #444); + @include mixins.lightDark(border-color, #DDD, #444); cursor: pointer; } .button.svg { display: flex; align-items: center; - padding: $-s $-m; - padding-bottom: ($-s - 2px); + padding: vars.$s vars.$m; + padding-bottom: (vars.$s - 2px); width: 100%; svg { display: inline-block; width: 24px; height: 24px; bottom: auto; - margin-inline-end: $-m; + margin-inline-end: vars.$m; } } diff --git a/resources/sass/_codemirror.scss b/resources/sass/_codemirror.scss index 5f14cb9dbc9..a516b4426a3 100644 --- a/resources/sass/_codemirror.scss +++ b/resources/sass/_codemirror.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Custom CodeMirror BookStack overrides */ @@ -6,7 +9,7 @@ font-size: 12px; border: 1px solid #ddd; line-height: 1.4; - margin-bottom: $-l; + margin-bottom: vars.$l; } .page-content .cm-editor, @@ -42,9 +45,9 @@ background-color: #EEE; border: 1px solid #DDD; border-start-end-radius: 4px; - @include lightDark(background-color, #eee, #333); - @include lightDark(border-color, #ddd, #444); - @include lightDark(color, #444, #888); + @include mixins.lightDark(background-color, #eee, #333); + @include mixins.lightDark(border-color, #ddd, #444); + @include mixins.lightDark(color, #444, #888); line-height: 0; cursor: pointer; z-index: 5; diff --git a/resources/sass/_colors.scss b/resources/sass/_colors.scss index c77c1d8b388..bf7a7a0fc03 100644 --- a/resources/sass/_colors.scss +++ b/resources/sass/_colors.scss @@ -1,3 +1,5 @@ +@use "mixins"; + /** * Background colors */ @@ -7,7 +9,7 @@ } .primary-background-light { background-color: var(--color-primary-light); - @include whenDark { + @include mixins.whenDark { background: #000; .text-link { color: #AAA !important; @@ -50,12 +52,12 @@ } .text-muted { - @include lightDark(color, #575757, #888888, true); + @include mixins.lightDark(color, #575757, #888888, true); fill: currentColor !important; } .text-dark { - @include lightDark(color, #222, #ccc, true); + @include mixins.lightDark(color, #222, #ccc, true); fill: currentColor !important; } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 18d1bc18f0c..888b325275e 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1,16 +1,21 @@ +@use "sass:math"; + +@use "mixins"; +@use "vars"; + // System wide notifications .notification { position: fixed; top: 0; right: 0; - margin: $-xl; - padding: $-m $-l; + margin: vars.$xl; + padding: vars.$m vars.$l; background-color: #FFF; - @include lightDark(background-color, #fff, #444); + @include mixins.lightDark(background-color, #fff, #444); border-radius: 4px; border-inline-start: 6px solid currentColor; - box-shadow: $bs-large; + box-shadow: vars.$bs-large; z-index: 999999; cursor: pointer; max-width: 360px; @@ -28,20 +33,20 @@ svg { width: 2.8rem; height: 2.8rem; - padding-inline-end: $-s; + padding-inline-end: vars.$s; fill: currentColor; } .dismiss { margin-top: -8px; svg { height: 1.0rem; - @include lightDark(color, #444, #888); + @include mixins.lightDark(color, #444, #888); } } span { vertical-align: middle; line-height: 1.3; - @include whenDark { + @include mixins.whenDark { color: #BBB; } } @@ -78,12 +83,12 @@ transform: rotate(90deg); } svg[data-icon="caret-right"] + * { - margin-inline-start: $-xxs; + margin-inline-start: vars.$xxs; } } [overlay], .popup-background { - @include lightDark(background-color, rgba(0, 0, 0, 0.333), rgba(0, 0, 0, 0.6)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.333), rgba(0, 0, 0, 0.6)); position: fixed; z-index: 95536; width: 100%; @@ -104,7 +109,7 @@ } .popup-body { - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(background-color, #fff, #333); max-height: 90%; max-width: 1200px; width: 90%; @@ -144,7 +149,7 @@ border-radius: 0; box-shadow: none; color: #FFF; - padding: $-xs $-m; + padding: vars.$xs vars.$m; cursor: pointer; } @@ -161,7 +166,7 @@ background-color: var(--color-primary-light); min-height: 41px; button { - padding: 10px $-m; + padding: 10px vars.$m; } } @@ -183,7 +188,7 @@ .popup-title { color: #FFF; margin-inline-end: auto; - padding: 8px $-m; + padding: 8px vars.$m; } &.flex-container-row { display: flex !important; @@ -226,7 +231,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .dropzone-landing-area { background-color: var(--color-primary-light); - padding: $-m $-l; + padding: vars.$m vars.$l; width: 100%; border: 1px dashed var(--color-primary); color: var(--color-primary); @@ -275,8 +280,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: flex; margin: 1rem; flex-direction: row; - @include lightDark(background, #FFF, #444); - box-shadow: $bs-large; + @include mixins.lightDark(background, #FFF, #444); + box-shadow: vars.$bs-large; border-radius: 4px; overflow: hidden; padding-bottom: 3px; @@ -354,7 +359,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { position: sticky; top: 0; z-index: 5; - @include lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85)); + @include mixins.lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85)); } .image-manager-filter-bar-bg { position: absolute; @@ -367,16 +372,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .image-manager-filters { - box-shadow: $bs-med; + box-shadow: vars.$bs-med; border-radius: 4px; overflow: hidden; border-bottom: 0 !important; - @include whenDark { + @include mixins.whenDark { border: 1px solid #000 !important; } button { line-height: 0; - @include lightDark(background-color, #FFF, #333); + @include mixins.lightDark(background-color, #FFF, #333); } svg { margin: 0; @@ -404,7 +409,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { padding: 0; cursor: pointer; aspect-ratio: 1; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); transition: all linear 80ms; overflow: hidden; &.selected { @@ -441,7 +446,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { &:focus .image-meta { opacity: 1; } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { .image-meta { display: none; } @@ -450,7 +455,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .image-manager .load-more { text-align: center; - padding: $-s $-m; + padding: vars.$s vars.$m; clear: both; .loading-container { margin: 0; @@ -467,7 +472,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .image-manager-warning { - @include lightDark(background, #FFF, #333); + @include mixins.lightDark(background, #FFF, #333); color: var(--color-warning); font-weight: bold; border-inline: 3px solid var(--color-warning); @@ -479,16 +484,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { overflow-y: auto; overflow-x: hidden; border-inline-start: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); .inner { min-height: auto; - padding: $-m; + padding: vars.$m; } .image-manager-viewer img { max-width: 100%; max-height: 180px; display: block; - margin: 0 auto $-m auto; + margin: 0 auto vars.$m auto; box-shadow: 0 1px 21px 1px rgba(76, 76, 76, 0.3); } .image-manager-viewer { @@ -501,7 +506,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .image-manager-sidebar { border-inline-start: 0; } @@ -522,7 +527,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .tab-container.bordered [role="tablist"] button[role="tab"] { border-inline-end: 1px solid #DDD; - @include lightDark(border-inline-end-color, #DDD, #000); + @include mixins.lightDark(border-inline-end-color, #DDD, #000); &:last-child { border-inline-end: none; } @@ -534,14 +539,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { justify-items: start; text-align: start; border-bottom: 1px solid #DDD; - @include lightDark(border-color, #ddd, #444); - margin-bottom: $-m; + @include mixins.lightDark(border-color, #ddd, #444); + margin-bottom: vars.$m; } .tab-container [role="tablist"] button[role="tab"] { display: inline-block; - padding: $-s; - @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); + padding: vars.$s; + @include mixins.lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; @@ -551,8 +556,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { outline: 0 !important; } &:hover, &:focus { - @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); - @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); + @include mixins.lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); + @include mixins.lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); } &:focus { outline: 1px dotted var(--color-primary); @@ -562,7 +567,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .tab-container [role="tablist"].controls-card { margin-bottom: 0; border-bottom: 0; - padding: 0 $-xs; + padding: 0 vars.$xs; } .image-picker .none { @@ -583,16 +588,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .code-editor .lang-options button { display: block; - padding: $-xs $-m; + padding: vars.$xs vars.$m; border-bottom: 1px solid; - @include lightDark(color, #333, #AAA); - @include lightDark(border-bottom-color, #EEE, #000); + @include mixins.lightDark(color, #333, #AAA); + @include mixins.lightDark(border-bottom-color, #EEE, #000); cursor: pointer; width: 100%; text-align: left; font-family: var(--font-code); font-size: 0.7rem; - padding-left: 24px + $-xs; + padding-left: 24px + vars.$xs; &:hover, &.active { background-color: var(--color-primary-light); color: var(--color-primary); @@ -633,7 +638,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { background-color: var(--color-primary-light); width: 100%; color: var(--color-primary); - padding: $-xxs $-s; + padding: vars.$xxs vars.$s; margin-bottom: 0; } @@ -648,7 +653,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border-radius: 0; border: 0; border-bottom: 1px solid #DDD; - padding: $-xs $-s; + padding: vars.$xs vars.$s; height: auto; } @@ -667,7 +672,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { height: 80vh; } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .code-editor .lang-options { display: none; } @@ -680,21 +685,21 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .comments-container { - padding-inline: $-xl; - @include smaller-than($m) { - padding-inline: $-xs; + padding-inline: vars.$xl; + @include mixins.smaller-than(vars.$bp-m) { + padding-inline: vars.$xs; } } .comment-box { border-radius: 4px; border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #ddd, #000); + @include mixins.lightDark(background-color, #FFF, #222); .content { font-size: 0.666em; - padding: $-xs $-s; + padding: vars.$xs vars.$s; p, ul, ol { - font-size: $fs-m; + font-size: vars.$fs-m; margin: .5em 0; } } @@ -708,7 +713,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .actions button:focus { outline: 1px dotted var(--color-primary); } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { .actions { opacity: 1; } @@ -717,8 +722,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .comment-box .header { border-bottom: 1px solid #DDD; - padding: $-xs $-s; - @include lightDark(border-color, #DDD, #000); + padding: vars.$xs vars.$s; + @include mixins.lightDark(border-color, #DDD, #000); a { color: inherit; } @@ -735,10 +740,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .comment-thread-indicator { border-inline-start: 3px dotted #DDD; - @include lightDark(border-color, #DDD, #444); - margin-inline-start: $-xs; - width: $-l; - height: calc(100% - $-m); + @include mixins.lightDark(border-color, #DDD, #444); + margin-inline-start: vars.$xs; + width: vars.$l; + height: calc(100% - vars.$m); } .comment-branch .comment-branch .comment-branch .comment-branch .comment-thread-indicator { @@ -748,7 +753,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .comment-reply { display: none; margin: 0 !important; - margin-bottom: -$-xxs !important; + margin-bottom: -(vars.$xxs) !important; } .comment-branch .comment-branch .comment-branch .comment-branch .comment-reply { @@ -760,17 +765,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { font-size: 0.8rem; } .header { - padding: $-xs; + padding: vars.$xs; } .right-meta { display: none; } .content { - padding: $-xs $-s; + padding: vars.$xs vars.$s; } } .comment-container-compact .comment-thread-indicator { - width: $-m; + width: vars.$m; } #tag-manager .drag-card { @@ -792,15 +797,15 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: flex; flex-direction: column; border-inline-start: 1px solid; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); } .template-item-actions button { cursor: pointer; flex: 1; - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); border: 0; border-top: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); } .template-item-actions button svg { margin: 0; @@ -818,7 +823,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border: 1px solid transparent; border-radius: 4px; line-height: normal; - padding: $-xs; + padding: vars.$xs; &:hover { border-color: #DDD; } @@ -828,7 +833,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .dropdown-search-toggle-select { display: flex; - gap: $-s; + gap: vars.$s; line-height: normal; .svg-icon { height: 26px; @@ -862,7 +867,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .dropdown-search-dropdown { - box-shadow: $bs-med; + box-shadow: vars.$bs-med; overflow: hidden; min-height: 100px; width: 240px; @@ -871,16 +876,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { z-index: 80; right: 0; top: 0; - margin-top: $-m; - @include rtl { + margin-top: vars.$m; + @include mixins.rtl { right: auto; - left: -$-m; + left: -(vars.$m); } .dropdown-search-search .svg-icon { position: absolute; - left: $-s; - @include rtl { - right: $-s; + left: vars.$s; + @include mixins.rtl { + right: vars.$s; left: auto; } top: 11px; @@ -893,14 +898,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { text-align: start; } .dropdown-search-item { - padding: $-s $-m; + padding: vars.$s vars.$m; &:hover,&:focus { background-color: #F2F2F2; text-decoration: none; } } input, input:focus { - padding-inline-start: $-xl; + padding-inline-start: vars.$xl; border-radius: 0; border: 0; border-bottom: 1px solid #DDD; @@ -910,9 +915,9 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { .dropdown-search-dropdown { - inset-inline: $-m auto; + inset-inline: vars.$m auto; } .dropdown-search-dropdown .dropdown-search-list { max-height: 240px; @@ -924,13 +929,13 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .item-list-row { border: 1.5px solid; - @include lightDark(border-color, #E2E2E2, #444); + @include mixins.lightDark(border-color, #E2E2E2, #444); border-bottom-width: 0; label { padding-bottom: 0; } &:hover { - @include lightDark(background-color, #F6F6F6, #333); + @include mixins.lightDark(background-color, #F6F6F6, #333); } } .item-list-row:first-child { @@ -980,7 +985,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .shortcut-hint { position: fixed; - padding: $-xxs $-xxs; + padding: vars.$xxs vars.$xxs; font-size: .85rem; font-weight: 700; line-height: 1; @@ -996,8 +1001,8 @@ $btt-size: 40px; .back-to-top { background-color: var(--color-primary); position: fixed; - bottom: $-m; - right: $-l; + bottom: vars.$m; + right: vars.$l; padding: 5px 7px; cursor: pointer; color: #FFF; @@ -1036,17 +1041,17 @@ $btt-size: 40px; max-height: 280px; overflow-y: scroll; border: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); border-radius: 3px; min-height: 20px; - @include lightDark(background-color, #EEE, #000); + @include mixins.lightDark(background-color, #EEE, #000); } .scroll-box-item { border-bottom: 1px solid; border-top: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); margin-top: -1px; - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); display: flex; align-items: flex-start; padding: 1px; @@ -1055,7 +1060,7 @@ $btt-size: 40px; } &:hover { cursor: pointer; - @include lightDark(background-color, #f8f8f8, #333); + @include mixins.lightDark(background-color, #f8f8f8, #333); } .handle { color: #AAA; @@ -1068,13 +1073,13 @@ $btt-size: 40px; margin: 0; } > * { - padding: $-xs $-m; + padding: vars.$xs vars.$m; } .handle + * { padding-left: 0; } &:hover .handle { - @include lightDark(color, #444, #FFF); + @include mixins.lightDark(color, #444, #FFF); } &:hover button { opacity: 1; @@ -1087,8 +1092,8 @@ $btt-size: 40px; input.scroll-box-search, .scroll-box-header-item { font-size: 0.8rem; border: 1px solid; - @include lightDark(border-color, #DDD, #000); - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #DDD, #000); + @include mixins.lightDark(background-color, #FFF, #222); margin-bottom: -1px; border-radius: 3px 3px 0 0; width: 100%; diff --git a/resources/sass/_content.scss b/resources/sass/_content.scss index 7d130bb0c24..b0176d64ef1 100644 --- a/resources/sass/_content.scss +++ b/resources/sass/_content.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Page Content * Styles specific to blocks used within page content. @@ -13,14 +16,14 @@ } img.align-left, table.align-left, iframe.align-left, video.align-left { float: left !important; - margin: $-xs $-m $-m 0; + margin: vars.$xs vars.$m vars.$m 0; } .align-right { text-align: right !important; } img.align-right, table.align-right, iframe.align-right, video.align-right { float: right !important; - margin: $-xs 0 $-xs $-s; + margin: vars.$xs 0 vars.$xs vars.$s; } .align-center { text-align: center; @@ -40,7 +43,7 @@ } hr { clear: both; - margin: $-m 0; + margin: vars.$m 0; } table { hyphens: auto; @@ -63,23 +66,23 @@ details { border: 1px solid; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); margin-bottom: 1em; - padding: $-s; + padding: vars.$s; } details > summary { - margin-top: -$-s; - margin-left: -$-s; - margin-right: -$-s; - margin-bottom: -$-s; + margin-top: -(vars.$s); + margin-left: -(vars.$s); + margin-right: -(vars.$s); + margin-bottom: -(vars.$s); font-weight: bold; - @include lightDark(background-color, #EEE, #333); - padding: $-xs $-s; + @include mixins.lightDark(background-color, #EEE, #333); + padding: vars.$xs vars.$s; } details[open] > summary { - margin-bottom: $-s; + margin-bottom: vars.$s; border-bottom: 1px solid; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); } details > summary + * { margin-top: .2em; @@ -138,10 +141,10 @@ body .page-content img, border-inline-start: 3px solid #BBB; border-inline-end: none; background-color: #EEE; - padding: $-s; - padding-left: $-xl; - padding-inline-start: $-xl; - padding-inline-end: $-s; + padding: vars.$s; + padding-left: vars.$xl; + padding-inline-start: vars.$xl; + padding-inline-end: vars.$s; display: block; position: relative; overflow: auto; @@ -151,8 +154,8 @@ body .page-content img, content: ''; width: 1.2em; height: 1.2em; - left: $-xs + 2px; - inset-inline-start: $-xs + 2px; + left: vars.$xs + 2px; + inset-inline-start: vars.$xs + 2px; inset-inline-end: unset; top: 50%; margin-top: -9px; @@ -162,30 +165,30 @@ body .page-content img, opacity: 0.8; } &.success { - @include lightDark(border-color, $positive, $positive-dark); - @include lightDark(background-color, #eafdeb, #122913); - @include lightDark(color, #063409, $positive-dark); + @include mixins.lightDark(border-color, vars.$positive, vars.$positive-dark); + @include mixins.lightDark(background-color, #eafdeb, #122913); + @include mixins.lightDark(color, #063409, vars.$positive-dark); } &.success:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiMzNzZjMzkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEyIDJDNi40OCAyIDIgNi40OCAyIDEyczQuNDggMTAgMTAgMTAgMTAtNC40OCAxMC0xMFMxNy41MiAyIDEyIDJ6bS0yIDE1bC01LTUgMS40MS0xLjQxTDEwIDE0LjE3bDcuNTktNy41OUwxOSA4bC05IDl6Ii8+PC9zdmc+"); } &.danger { - @include lightDark(border-color, $negative, $negative-dark); - @include lightDark(background-color, #fcdbdb, #250505); - @include lightDark(color, #4d0706, $negative-dark); + @include mixins.lightDark(border-color, vars.$negative, vars.$negative-dark); + @include mixins.lightDark(background-color, #fcdbdb, #250505); + @include mixins.lightDark(color, #4d0706, vars.$negative-dark); } &.danger:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiOTE4MTgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0xNS43MyAzSDguMjdMMyA4LjI3djcuNDZMOC4yNyAyMWg3LjQ2TDIxIDE1LjczVjguMjdMMTUuNzMgM3pNMTIgMTcuM2MtLjcyIDAtMS4zLS41OC0xLjMtMS4zIDAtLjcyLjU4LTEuMyAxLjMtMS4zLjcyIDAgMS4zLjU4IDEuMyAxLjMgMCAuNzItLjU4IDEuMy0xLjMgMS4zem0xLTQuM2gtMlY3aDJ2NnoiLz4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPjwvc3ZnPg=="); } &.info { - @include lightDark(border-color, $info, $info-dark); - @include lightDark(background-color, #d3efff, #001825); - @include lightDark(color, #01466c, $info-dark); + @include mixins.lightDark(border-color, vars.$info, vars.$info-dark); + @include mixins.lightDark(background-color, #d3efff, #001825); + @include mixins.lightDark(color, #01466c, vars.$info-dark); } &.warning { - @include lightDark(border-color, $warning, $warning-dark); - @include lightDark(background-color, #fee3d3, #30170a); - @include lightDark(color, #6a2802, $warning-dark); + @include mixins.lightDark(border-color, vars.$warning, vars.$warning-dark); + @include mixins.lightDark(background-color, #fee3d3, #30170a); + @include mixins.lightDark(color, #6a2802, vars.$warning-dark); } &.warning:before { background-image: url("data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNiNjUzMWMiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+ICAgIDxwYXRoIGQ9Ik0wIDBoMjR2MjRIMHoiIGZpbGw9Im5vbmUiLz4gICAgPHBhdGggZD0iTTEgMjFoMjJMMTIgMiAxIDIxem0xMi0zaC0ydi0yaDJ2MnptMC00aC0ydi00aDJ2NHoiLz48L3N2Zz4="); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index b33cb4d0558..e273f1942d5 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + // Common variables :root { --editor-color-primary: #206ea7; @@ -262,11 +265,11 @@ body.editor-is-fullscreen { color: #FFF; } .editor-modal-title { - padding: 8px $-m; + padding: 8px vars.$m; } .editor-modal-close { color: #FFF; - padding: 8px $-m; + padding: 8px vars.$m; align-items: center; justify-content: center; cursor: pointer; @@ -281,7 +284,7 @@ body.editor-is-fullscreen { } } .editor-modal-body { - padding: $-m; + padding: vars.$m; } // Specific UI elements @@ -493,21 +496,21 @@ textarea.editor-form-field-input { .editor-form-actions { display: flex; justify-content: end; - gap: $-s; - margin-top: $-m; + gap: vars.$s; + margin-top: vars.$m; } .editor-form-actions > button { display: block; font-size: 0.85rem; line-height: 1.4em; - padding: $-xs*1.3 $-m; + padding: vars.$xs*1.3 vars.$m; font-weight: 400; border-radius: 4px; cursor: pointer; box-shadow: none; &:focus { outline: 1px dotted currentColor; - outline-offset: -$-xs; + outline-offset: -(vars.$xs); box-shadow: none; filter: brightness(90%); } @@ -517,20 +520,20 @@ textarea.editor-form-field-input { color: #FFF; border: 1px solid var(--color-primary); &:hover { - @include lightDark(box-shadow, $bs-light, $bs-dark); + @include mixins.lightDark(box-shadow, vars.$bs-light, vars.$bs-dark); filter: brightness(110%); } } .editor-form-action-secondary { border: 1px solid; - @include lightDark(border-color, #CCC, #666); - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(border-color, #CCC, #666); + @include mixins.lightDark(color, #666, #AAA); &:hover, &:focus, &:active { - @include lightDark(color, #444, #BBB); + @include mixins.lightDark(color, #444, #BBB); border: 1px solid #CCC; box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); background-color: #F2F2F2; - @include lightDark(background-color, #f8f8f8, #444); + @include mixins.lightDark(background-color, #f8f8f8, #444); filter: none; } &:active { diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 1c679aaa0dd..b66688f8d20 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -1,13 +1,18 @@ +@use "sass:math"; + +@use "mixins"; +@use "vars"; + .input-base { border-radius: 3px; border: 1px solid #D4D4D4; - @include lightDark(background-color, #fff, #333); - @include lightDark(border-color, #d4d4d4, #111); - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(background-color, #fff, #333); + @include mixins.lightDark(border-color, #d4d4d4, #111); + @include mixins.lightDark(color, #666, #AAA); display: inline-block; - font-size: $fs-m; - padding: $-xs*1.8; + font-size: vars.$fs-m; + padding: vars.$xs*1.8; height: 40px; width: 250px; max-width: 100%; @@ -49,7 +54,7 @@ #markdown-editor-input { font-style: normal; font-weight: 400; - padding: $-xs $-m; + padding: vars.$xs vars.$m; color: #444; border-radius: 0; max-height: 100%; @@ -72,7 +77,7 @@ .markdown-editor-wrap { border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); position: relative; flex: 1; min-width: 0; @@ -92,11 +97,11 @@ .markdown-panel-divider { width: 2px; - @include lightDark(background-color, #ddd, #000); + @include mixins.lightDark(background-color, #ddd, #000); cursor: col-resize; } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { #markdown-editor { flex-direction: column; } @@ -109,7 +114,7 @@ } .editor-toolbar-label { float: none !important; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); display: block; } .markdown-editor-wrap:not(.active) .editor-toolbar + div, @@ -150,10 +155,10 @@ html.markdown-editor-display.dark-mode { font-size: 11px; line-height: 1.6; border-bottom: 1px solid #CCC; - @include lightDark(background-color, #FFF, #333); - @include lightDark(border-color, #CCC, #000); + @include mixins.lightDark(background-color, #FFF, #333); + @include mixins.lightDark(border-color, #CCC, #000); flex: none; - @include whenDark { + @include mixins.whenDark { button { color: #AAA; } @@ -161,12 +166,12 @@ html.markdown-editor-display.dark-mode { } .editor-toolbar .buttons { - font-size: $fs-m; + font-size: vars.$fs-m; .dropdown-menu { padding: 0; } .toggle-switch { - margin: $-s 0; + margin: vars.$s 0; } } @@ -175,18 +180,18 @@ html.markdown-editor-display.dark-mode { width: 2rem; text-align: center; border-left: 1px solid; - @include lightDark(border-color, #DDD, #555); + @include mixins.lightDark(border-color, #DDD, #555); svg { margin-inline-end: 0; } &:hover { - @include lightDark(background-color, #DDD, #222); + @include mixins.lightDark(background-color, #DDD, #222); } } label { - @include lightDark(color, #666, #ddd); + @include mixins.lightDark(color, #666, #ddd); display: block; line-height: 1.4em; font-size: 0.94em; @@ -202,12 +207,12 @@ label.radio, label.checkbox { font-weight: 400; user-select: none; input[type="radio"], input[type="checkbox"] { - margin-inline-end: $-xs; + margin-inline-end: vars.$xs; } } label.inline.checkbox { - margin-inline-end: $-m; + margin-inline-end: vars.$m; } label + p.small { @@ -218,7 +223,7 @@ table.form-table { max-width: 100%; td { overflow: hidden; - padding: math.div($-xxs, 2) 0; + padding: math.div(vars.$xxs, 2) 0; } } @@ -236,7 +241,7 @@ select { background-position: calc(100% - 20px) 64%; background-repeat: no-repeat; - @include rtl { + @include mixins.rtl { background-position: 20px 70%; } } @@ -257,9 +262,9 @@ input[type=color] { .toggle-switch { user-select: none; display: inline-grid; - grid-template-columns: (16px + $-s) 1fr; + grid-template-columns: (16px + vars.$s) 1fr; align-items: center; - margin: $-m 0; + margin: vars.$m 0; .custom-checkbox { width: 16px; height: 16px; @@ -302,7 +307,7 @@ input[type=color] { } .toggle-switch-list { .toggle-switch { - margin: $-xs 0; + margin: vars.$xs 0; } &.compact .toggle-switch { margin: 1px 0; @@ -310,18 +315,18 @@ input[type=color] { } .form-group { - margin-bottom: $-s; + margin-bottom: vars.$s; } .setting-list > div { border-bottom: 1px solid #DDD; - padding: $-xl 0; + padding: vars.$xl 0; &:last-child { border-bottom: none; } } .setting-list-label { - @include lightDark(color, #222, #DDD); + @include mixins.lightDark(color, #222, #DDD); color: #222; font-size: 1rem; } @@ -329,7 +334,7 @@ input[type=color] { margin-bottom: 0; } .setting-list-label + .grid { - margin-top: $-m; + margin-top: vars.$m; } .setting-list .grid, .stretch-inputs { @@ -349,19 +354,19 @@ input[type=color] { .form-group { div.text-pos, div.text-neg, p.text-post, p.text-neg { - padding: $-xs 0; + padding: vars.$xs 0; } } .form-group.collapsible { - padding: 0 $-m; + padding: 0 vars.$m; border: 1px solid; - @include lightDark(border-color, #DDD, #000); + @include mixins.lightDark(border-color, #DDD, #000); border-radius: 4px; .collapse-title { - margin-inline-start: -$-m; - margin-inline-end: -$-m; - padding: $-s $-m; + margin-inline-start: -(vars.$m); + margin-inline-end: -(vars.$m); + padding: vars.$s vars.$m; display: block; width: calc(100% + 32px); text-align: start; @@ -377,13 +382,13 @@ input[type=color] { .collapse-title label:before { display: inline-block; content: 'â–¸'; - margin-inline-end: $-m; + margin-inline-end: vars.$m; transition: all ease-in-out 400ms; transform: rotate(0); } .collapse-content { display: none; - padding-bottom: $-m; + padding-bottom: vars.$m; } &.open .collapse-title label:before { transform: rotate(90deg); @@ -407,7 +412,7 @@ input[type=color] { .title-input input[type="text"] { display: block; width: 100%; - padding: $-s; + padding: vars.$s; margin-top: 0; font-size: 2em; height: auto; @@ -416,15 +421,15 @@ input[type=color] { .description-input textarea { display: block; width: 100%; - padding: $-s; - font-size: $fs-m; + padding: vars.$s; + font-size: vars.$fs-m; color: #666; height: auto; } .description-input > .tox-tinymce { border: 1px solid #DDD !important; - @include lightDark(border-color, #DDD !important, #000 !important); + @include mixins.lightDark(border-color, #DDD !important, #000 !important); border-radius: 3px; .tox-toolbar__primary { justify-content: end; @@ -437,7 +442,7 @@ input[type=color] { button[tabindex="-1"] { background-color: transparent; border: none; - @include lightDark(color, #666, #AAA); + @include mixins.lightDark(color, #666, #AAA); padding: 0; cursor: pointer; position: absolute; @@ -446,8 +451,8 @@ input[type=color] { } input { display: block; - padding: $-xs * 1.5; - padding-inline-start: $-l + 4px; + padding: vars.$xs * 1.5; + padding-inline-start: vars.$l + 4px; width: 300px; max-width: 100%; height: auto; @@ -466,10 +471,10 @@ input[type=color] { height: 38px; z-index: -1; &.floating { - box-shadow: $bs-med; + box-shadow: vars.$bs-med; border-radius: 4px; overflow: hidden; - @include whenDark { + @include mixins.whenDark { border: 1px solid #000; } } @@ -477,7 +482,7 @@ input[type=color] { height: 100%; border-radius: 0; border: 1px solid #ddd; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); margin-inline-start: -1px; &:last-child { border-inline-end: 0; @@ -486,7 +491,7 @@ input[type=color] { input { border: 0; flex: 5; - padding: $-xs $-s; + padding: vars.$xs vars.$s; &:focus, &:active { outline: 1px dotted var(--color-primary); outline-offset: -2px; @@ -498,8 +503,8 @@ input[type=color] { width: 48px; border-inline-start: 1px solid #DDD; background-color: #FFF; - @include lightDark(background-color, #FFF, #333); - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(background-color, #FFF, #333); + @include mixins.lightDark(color, #444, #AAA); } button:focus { outline: 1px dotted var(--color-primary); @@ -508,7 +513,7 @@ input[type=color] { svg { margin: 0; } - @include smaller-than($s) { + @include mixins.smaller-than(vars.$bp-s) { width: 180px; } } @@ -548,30 +553,30 @@ input[type=color] { .custom-simple-file-input { max-width: 100%; border: 1px solid; - @include lightDark(border-color, #DDD, #666); + @include mixins.lightDark(border-color, #DDD, #666); border-radius: 4px; - padding: $-s $-m; + padding: vars.$s vars.$m; } .custom-simple-file-input::file-selector-button { background-color: transparent; text-decoration: none; font-size: 0.8rem; line-height: 1.4em; - padding: $-xs $-s; + padding: vars.$xs vars.$s; border: 1px solid; font-weight: 400; outline: 0; border-radius: 4px; cursor: pointer; - margin-right: $-m; - @include lightDark(color, #666, #AAA); - @include lightDark(border-color, #CCC, #666); + margin-right: vars.$m; + @include mixins.lightDark(color, #666, #AAA); + @include mixins.lightDark(border-color, #CCC, #666); &:hover, &:focus, &:active { - @include lightDark(color, #444, #BBB); + @include mixins.lightDark(color, #444, #BBB); border: 1px solid #CCC; box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1); background-color: #F2F2F2; - @include lightDark(background-color, #f8f8f8, #444); + @include mixins.lightDark(background-color, #f8f8f8, #444); filter: none; } &:active { diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index d72b6672948..3ec7275a480 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Includes the main navigation header and the faded toolbar. */ @@ -6,7 +9,7 @@ header.grid { grid-template-columns: minmax(max-content, 2fr) 1fr minmax(max-content, 2fr); } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { header.grid { grid-template-columns: 1fr; grid-row-gap: 0; @@ -20,8 +23,8 @@ header { top: 0; color: rgb(250, 250, 250); border-bottom: 1px solid #DDD; - box-shadow: $bs-card; - @include lightDark(border-bottom-color, #DDD, #000); + box-shadow: vars.$bs-card; + @include mixins.lightDark(border-bottom-color, #DDD, #000); .header-links { display: flex; align-items: center; @@ -33,7 +36,7 @@ header { } .links a { display: inline-block; - padding: 10px $-m; + padding: 10px vars.$m; color: #FFF; border-radius: 3px; } @@ -42,7 +45,7 @@ header { background-color: rgba(255, 255, 255, .15); } .dropdown-container { - padding-inline-start: $-m; + padding-inline-start: vars.$m; padding-inline-end: 0; } .avatar, .user-name { @@ -58,12 +61,12 @@ header { display: inline-flex; align-items: center; cursor: pointer; - padding: $-s; - margin: 0 (-$-s); + padding: vars.$s; + margin: 0 (-(vars.$s)); border-radius: 3px; - gap: $-xs; + gap: vars.$xs; > span { - padding-inline-start: $-xs; + padding-inline-start: vars.$xs; display: inline-block; line-height: 1; } @@ -75,8 +78,8 @@ header { &:hover { background-color: rgba(255, 255, 255, 0.15); } - @include between($l, $xl) { - padding-inline-start: $-xs; + @include mixins.between(vars.$bp-l, vars.$bp-xl) { + padding-inline-start: vars.$xs; .name { display: none; } @@ -98,7 +101,7 @@ header .search-box { color: #EEE; z-index: 2; height: auto; - padding: $-xs*1.5; + padding: vars.$xs*1.5; padding-inline-start: 40px; &:focus { outline: none; @@ -109,7 +112,7 @@ header .search-box { color: #FFF; opacity: 0.6; } - @include between($l, $xl) { + @include mixins.between(vars.$bp-l, vars.$bp-xl) { max-width: 200px; } &:focus-within #header-search-box-button { @@ -122,7 +125,7 @@ header .search-box { top: 10px; color: #FFF; opacity: 0.6; - @include lightDark(color, rgba(255, 255, 255, 0.8), #AAA); + @include mixins.lightDark(color, rgba(255, 255, 255, 0.8), #AAA); svg { margin-inline-end: 0; } @@ -131,15 +134,15 @@ header .search-box { .global-search-suggestions { display: none; position: absolute; - top: -$-s; + top: -(vars.$s); left: 0; right: 0; z-index: -1; - margin-left: -$-xxl; - margin-right: -$-xxl; + margin-left: -(vars.$xxl); + margin-right: -(vars.$xxl); padding-top: 56px; border-radius: 3px; - box-shadow: $bs-hover; + box-shadow: vars.$bs-hover; transform-origin: top center; opacity: .5; transform: scale(0.9); @@ -166,19 +169,19 @@ header .search-box.search-active:focus-within { display: block; } input { - @include lightDark(background-color, #EEE, #333); - @include lightDark(border-color, #DDD, #111); + @include mixins.lightDark(background-color, #EEE, #333); + @include mixins.lightDark(border-color, #DDD, #111); } #header-search-box-button, input { - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(color, #444, #AAA); } } .logo { display: inline-flex; - padding: ($-s - 6px) $-s; - margin: 6px (-$-s); - gap: $-s; + padding: (vars.$s - 6px) vars.$s; + margin: 6px (-(vars.$s)); + gap: vars.$s; align-items: center; border-radius: 4px; &:hover { @@ -204,7 +207,7 @@ header .search-box.search-active:focus-within { font-size: 2em; border: 2px solid rgba(255, 255, 255, 0.8); border-radius: 4px; - padding: 0 $-xs; + padding: 0 vars.$xs; line-height: 1; cursor: pointer; user-select: none; @@ -215,18 +218,18 @@ header .search-box.search-active:focus-within { } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { header .header-links { - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(background-color, #fff, #333); display: none; z-index: 10; - inset-inline-end: $-m; + inset-inline-end: vars.$m; border-radius: 4px; overflow: hidden; position: absolute; - box-shadow: $bs-hover; - margin-top: $-m; - padding: $-xs 0; + box-shadow: vars.$bs-hover; + margin-top: vars.$m; + padding: vars.$xs 0; &.show { display: block; } @@ -235,14 +238,14 @@ header .search-box.search-active:focus-within { text-align: start; display: grid; align-items: center; - padding: 8px $-m; - gap: $-m; - color: $text-dark; + padding: 8px vars.$m; + gap: vars.$m; + color: vars.$text-dark; grid-template-columns: 16px auto; line-height: 1.4; - @include lightDark(color, $text-dark, #eee); + @include mixins.lightDark(color, vars.$text-dark, #eee); svg { - margin-inline-end: $-s; + margin-inline-end: vars.$s; width: 16px; } &:hover { @@ -251,7 +254,7 @@ header .search-box.search-active:focus-within { text-decoration: none; } &:focus { - @include lightDark(background-color, #eee, #333); + @include mixins.lightDark(background-color, #eee, #333); outline-color: var(--color-primary); color: var(--color-primary); } @@ -280,19 +283,19 @@ header .search-box.search-active:focus-within { z-index: 5; background-color: #FFF; border-bottom: 1px solid #DDD; - @include lightDark(border-bottom-color, #DDD, #333); - box-shadow: $bs-card; + @include mixins.lightDark(border-bottom-color, #DDD, #333); + box-shadow: vars.$bs-card; } .tri-layout-mobile-tab { text-align: center; border-bottom: 3px solid #BBB; cursor: pointer; margin: 0; - @include lightDark(background-color, #FFF, #222); - @include lightDark(border-bottom-color, #BBB, #333); + @include mixins.lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-bottom-color, #BBB, #333); &:first-child { border-inline-end: 1px solid #DDD; - @include lightDark(border-inline-end-color, #DDD, #000); + @include mixins.lightDark(border-inline-end-color, #DDD, #000); } &[aria-selected="true"] { border-bottom-color: currentColor !important; @@ -308,8 +311,8 @@ header .search-box.search-active:focus-within { opacity: 0.7; .icon-list-item { width: auto; - padding-top: $-xs; - padding-bottom: $-xs; + padding-top: vars.$xs; + padding-bottom: vars.$xs; } .separator { display: inline-block; @@ -326,9 +329,9 @@ header .search-box.search-active:focus-within { } } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { .breadcrumbs .icon-list-item { - padding: $-xs; + padding: vars.$xs; > span + span { display: none; } @@ -355,5 +358,5 @@ header .search-box.search-active:focus-within { .faded span.faded-text { display: inline-block; - padding: $-s; + padding: vars.$s; } \ No newline at end of file diff --git a/resources/sass/_html.scss b/resources/sass/_html.scss index 1d5defa9765..edaff081045 100644 --- a/resources/sass/_html.scss +++ b/resources/sass/_html.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + * { box-sizing: border-box; outline-color: var(--color-primary); @@ -21,9 +24,9 @@ html { } body { - font-size: $fs-m; + font-size: vars.$fs-m; line-height: 1.6; - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(color, #444, #AAA); -webkit-font-smoothing: antialiased; height: 100%; display: flex; diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 6c78419d86c..8175db948a5 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -1,13 +1,16 @@ +@use "mixins"; +@use "vars"; + /** * Generic content container */ .container { - max-width: $xxl; + max-width: vars.$bp-xxl; margin-inline-start: auto; margin-inline-end: auto; - padding-inline-start: $-m; - padding-inline-end: $-m; + padding-inline-start: vars.$m; + padding-inline-end: vars.$m; &.medium { max-width: 1100px; } @@ -24,8 +27,8 @@ */ .grid { display: grid; - grid-column-gap: $-l; - grid-row-gap: $-l; + grid-column-gap: vars.$l; + grid-row-gap: vars.$l; > * { min-width: 0; } @@ -42,15 +45,15 @@ grid-template-columns: 1fr 3fr; } &.gap-y-xs { - grid-row-gap: $-xs; + grid-row-gap: vars.$xs; } &.gap-xl { - grid-column-gap: $-xl; - grid-row-gap: $-xl; + grid-column-gap: vars.$xl; + grid-row-gap: vars.$xl; } &.gap-xxl { - grid-column-gap: $-xxl; - grid-row-gap: $-xxl; + grid-column-gap: vars.$xxl; + grid-row-gap: vars.$xxl; } &.v-center { align-items: center; @@ -67,7 +70,7 @@ } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .grid.third:not(.no-break) { grid-template-columns: 1fr 1fr; } @@ -78,8 +81,8 @@ grid-template-columns: 1fr 1fr; } .grid.gap-xl { - grid-column-gap: $-m; - grid-row-gap: $-m; + grid-column-gap: vars.$m; + grid-row-gap: vars.$m; } .grid.right-focus.reverse-collapse > *:nth-child(2) { order: 0; @@ -89,13 +92,13 @@ } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .grid.third:not(.no-break) { grid-template-columns: 1fr; } } -@include smaller-than($xs) { +@include mixins.smaller-than(vars.$bp-xs) { .grid.half.collapse-xs { grid-template-columns: 1fr; } @@ -287,19 +290,19 @@ body.flexbox { .sticky-top-m { position: sticky; - top: $-m; + top: vars.$m; } /** * Visibility */ -@each $sizeLetter, $size in $screen-sizes { - @include smaller-than($size) { +@each $sizeLetter, $size in vars.$screen-sizes { + @include mixins.smaller-than($size) { .hide-under-#{$sizeLetter} { display: none !important; } } - @include larger-than($size) { + @include mixins.larger-than($size) { .hide-over-#{$sizeLetter} { display: none !important; } @@ -333,7 +336,7 @@ body.flexbox { columns: 2; } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .dual-column-content { columns: 1; } @@ -357,11 +360,11 @@ body.flexbox { */ .tri-layout-container { display: grid; - margin-inline-start: $-xl; - margin-inline-end: $-xl; + margin-inline-start: vars.$xl; + margin-inline-end: vars.$xl; grid-template-columns: 1fr 4fr 1fr; grid-template-areas: "a b c"; - grid-column-gap: $-xl; + grid-column-gap: vars.$xl; position: relative; } .tri-layout-sides { @@ -379,7 +382,7 @@ body.flexbox { } .tri-layout-middle { grid-area: b; - padding-top: $-m; + padding-top: vars.$m; min-width: 0; z-index: 5; } @@ -392,9 +395,9 @@ body.flexbox { min-width: 0; } -@include larger-than($xxl) { +@include mixins.larger-than(vars.$bp-xxl) { .tri-layout-left-contents, .tri-layout-right-contents { - padding: $-xl $-m; + padding: vars.$xl vars.$m; position: sticky; top: 0; max-height: 100vh; @@ -413,22 +416,22 @@ body.flexbox { margin: 0 auto; } } -@include between($xxl, $xxxl) { +@include mixins.between(vars.$bp-xxl, vars.$bp-xxxl) { .tri-layout-sides-content, .tri-layout-container { - grid-template-columns: 1fr calc(940px + (2 * $-m)) 1fr; + grid-template-columns: 1fr calc(940px + (2 * vars.$m)) 1fr; } .tri-layout-container { - grid-column-gap: $-s; - margin-inline-start: $-m; - margin-inline-end: $-m; + grid-column-gap: vars.$s; + margin-inline-start: vars.$m; + margin-inline-end: vars.$m; } } -@include smaller-than($xxl) { +@include mixins.smaller-than(vars.$bp-xxl) { .tri-layout-container { grid-template-areas: "a b b"; grid-template-columns: 1fr 3fr; grid-template-rows: min-content min-content 1fr; - padding-inline-end: $-l; + padding-inline-end: vars.$l; } .tri-layout-sides { grid-column-start: a; @@ -438,7 +441,7 @@ body.flexbox { display: block; } } -@include between($l, $xxl) { +@include mixins.between(vars.$bp-l, vars.$bp-xxl) { .tri-layout-sides-content { position: sticky; top: 0; @@ -454,12 +457,12 @@ body.flexbox { } } } -@include larger-than($l) { +@include mixins.larger-than(vars.$bp-l) { .tri-layout-mobile-tabs { display: none; } .tri-layout-left-contents > *, .tri-layout-right-contents > * { - @include lightDark(opacity, 0.6, 0.75); + @include mixins.lightDark(opacity, 0.6, 0.75); transition: opacity ease-in-out 120ms; &:hover, &:focus-within { opacity: 1 !important; @@ -469,16 +472,16 @@ body.flexbox { } } } -@include smaller-than($l) { +@include mixins.smaller-than(vars.$bp-l) { .tri-layout-container { grid-template-areas: none; grid-template-columns: 1fr; grid-column-gap: 0; - padding-inline-end: $-xs; - padding-inline-start: $-xs; + padding-inline-end: vars.$xs; + padding-inline-start: vars.$xs; .tri-layout-sides { - padding-inline-start: $-m; - padding-inline-end: $-m; + padding-inline-start: vars.$m; + padding-inline-end: vars.$m; grid-column: 1/1; } .tri-layout-left > *, .tri-layout-right > * { @@ -512,7 +515,7 @@ body.flexbox { } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .tri-layout-container { margin-inline-start: 0; margin-inline-end: 0; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 00aa04f485c..fd76f498ed1 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + .book-contents .entity-list-item { .icon { @@ -6,7 +9,7 @@ justify-self: stretch; align-self: stretch; height: auto; - margin-inline-end: $-xs; + margin-inline-end: vars.$xs; } .icon:after { opacity: 0.5; @@ -25,7 +28,7 @@ .entity-list-item + .chapter-expansion { display: flex; - padding: 0 $-m $-m $-m; + padding: 0 vars.$m vars.$m vars.$m; align-items: center; border: 0; width: 100%; @@ -58,7 +61,7 @@ } .chapter-contents-toggle { border-radius: 0 4px 4px 0; - padding: $-xs ($-m + $-xxs); + padding: vars.$xs (vars.$m + vars.$xxs); width: 100%; text-align: start; } @@ -86,20 +89,20 @@ } .sidebar-page-nav { - $nav-indent: $-m; + $nav-indent: vars.$m; list-style: none; - @include margin($-s, 0, $-m, $-xs); + @include mixins.margin(vars.$s, 0, vars.$m, vars.$xs); position: relative; &:after { content: ''; display: block; position: absolute; left: 0; - @include rtl { + @include mixins.rtl { left: auto; right: 0; } - @include lightDark(background-color, rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); width: 2px; top: 5px; bottom: 5px; @@ -132,7 +135,7 @@ font-weight: bold; } li:not(.current-heading) .sidebar-page-nav-bullet { - @include lightDark(background-color, #BBB, #666, true); + @include mixins.lightDark(background-color, #BBB, #666, true); } .sidebar-page-nav-bullet { width: 6px; @@ -142,9 +145,9 @@ top: 30%; border-radius: 50%; box-shadow: 0 0 0 6px #F2F2F2; - @include lightDark(box-shadow, 0 0 0 6px #F2F2F2, 0 0 0 6px #111); + @include mixins.lightDark(box-shadow, 0 0 0 6px #F2F2F2, 0 0 0 6px #111); z-index: 1; - @include rtl { + @include mixins.rtl { left: auto; right: -2px; } @@ -154,7 +157,7 @@ // Sidebar list .book-tree .sidebar-page-list { list-style: none; - @include margin($-xs, -$-s, 0, -$-s); + @include mixins.margin(vars.$xs, -(vars.$s), 0, -(vars.$s)); padding-inline-start: 0; padding-inline-end: 0; @@ -172,22 +175,22 @@ padding-inline-end: 0; .content { width: 100%; - padding-top: $-xs; - padding-bottom: $-xs; + padding-top: vars.$xs; + padding-bottom: vars.$xs; max-width: calc(100% - 20px); } } .entity-list-item.selected { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } .entity-list-item.no-hover { - margin-top: -$-xs; + margin-top: -(vars.$xs); padding-inline-end: 0; } .entity-list-item-name { font-size: 1em; margin: 0; - margin-inline-end: $-m; + margin-inline-end: vars.$m; } .chapter-child-menu { font-size: .8rem; @@ -198,13 +201,13 @@ display: block; width: 100%; text-align: start; - padding: $-xxs $-s ($-xxs * 2) $-s; + padding: vars.$xxs vars.$s (vars.$xxs * 2) vars.$s; border-radius: 0 3px 3px 0; line-height: 1; - margin-top: -$-xxs; - margin-bottom: -$-xxs; + margin-top: -(vars.$xxs); + margin-bottom: -(vars.$xxs); &:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } } .entity-list-item .icon { @@ -236,8 +239,8 @@ list-style: none; } .sort-box { - margin-bottom: $-m; - padding: $-m $-xl; + margin-bottom: vars.$m; + padding: vars.$m vars.$xl; position: relative; &::before { pointer-events: none; @@ -279,14 +282,14 @@ margin-inline-start: 0; } .sortable-page-sublist { - margin-bottom: $-m; + margin-bottom: vars.$m; margin-top: 0; - padding-inline-start: $-m; + padding-inline-start: vars.$m; } li { - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); border: 1px solid; - @include lightDark(border-color, #DDD, #666); + @include mixins.lightDark(border-color, #DDD, #666); margin-top: -1px; min-height: 38px; } @@ -294,7 +297,7 @@ border-inline-start: 2px solid currentColor; } li:first-child { - margin-top: $-xs; + margin-top: vars.$xs; } } .sortable-page-list li.placeholder { @@ -335,21 +338,21 @@ details.sort-box[open] summary .caret-container svg { } .activity-list-item { - padding: $-s 0; + padding: vars.$s 0; display: grid; grid-template-columns: min-content 1fr; - grid-column-gap: $-m; + grid-column-gap: vars.$m; font-size: 0.9em; } .card .activity-list-item { - padding-block: $-s; + padding-block: vars.$s; } .user-list-item { display: inline-grid; - padding: $-s; + padding: vars.$s; grid-template-columns: min-content 1fr; - grid-column-gap: $-m; + grid-column-gap: vars.$m; font-size: 0.9em; align-items: center; > div:first-child { @@ -360,7 +363,7 @@ details.sort-box[open] summary .caret-container svg { ul.pagination { display: inline-flex; list-style: none; - margin: $-m 0; + margin: vars.$m 0; padding-inline-start: 1px; li:first-child { a, span { @@ -374,19 +377,19 @@ ul.pagination { } a, span { display: block; - padding: $-xxs $-s; + padding: vars.$xxs vars.$s; border: 1px solid #CCC; margin-inline-start: -1px; user-select: none; - @include lightDark(color, #555, #eee); - @include lightDark(border-color, #ccc, #666); + @include mixins.lightDark(color, #555, #eee); + @include mixins.lightDark(border-color, #ccc, #666); } li.disabled { cursor: not-allowed; } li.active span { - @include lightDark(color, #111, #eee); - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.5)); + @include mixins.lightDark(color, #111, #eee); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.5)); } } @@ -395,7 +398,7 @@ ul.pagination { } .entity-list, .icon-list { - margin: 0 (-$-m); + margin: 0 (-(vars.$m)); h4 { margin: 0; } @@ -405,7 +408,7 @@ ul.pagination { .text-small.text-muted { color: #AAA; font-size: 0.75em; - margin-top: $-xs; + margin-top: vars.$xs; } .text-muted p.text-muted { margin-top: 0; @@ -420,7 +423,7 @@ ul.pagination { } .icon-list hr { - margin: $-s $-m; + margin: vars.$s vars.$m; max-width: 140px; opacity: 0.25; height: 1.1px; @@ -431,10 +434,10 @@ ul.pagination { } .entity-list-item, .icon-list-item { - padding: $-s $-m; + padding: vars.$s vars.$m; display: flex; align-items: center; - gap: $-m; + gap: vars.$m; background-color: transparent; border: 0; width: 100%; @@ -458,7 +461,7 @@ ul.pagination { cursor: pointer; } &:not(.no-hover):hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); text-decoration: none; border-radius: 4px; } @@ -466,7 +469,7 @@ ul.pagination { background-color: transparent; } &:focus { - @include lightDark(background-color, #eee, #222); + @include mixins.lightDark(background-color, #eee, #222); outline: 1px dotted #666; outline-offset: -2px; } @@ -493,7 +496,7 @@ ul.pagination { .split-icon-list-item { display: flex; align-items: center; - gap: $-m; + gap: vars.$m; background-color: transparent; border: 0; width: 100%; @@ -501,10 +504,10 @@ ul.pagination { word-break: break-word; border-radius: 4px; > a { - padding: $-s $-m; + padding: vars.$s vars.$m; display: flex; align-items: center; - gap: $-m; + gap: vars.$m; flex: 1; } > a:hover { @@ -515,7 +518,7 @@ ul.pagination { flex: none; } &:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } } @@ -532,21 +535,21 @@ ul.pagination { visibility: visible; } .icon-list-item-dropdown-toggle { - padding: $-xs; + padding: vars.$xs; display: flex; align-items: center; cursor: pointer; - @include lightDark(color, #888, #999); + @include mixins.lightDark(color, #888, #999); svg { margin: 0; } &:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.06), rgba(255, 255, 255, 0.06)); } } .card .entity-list-item:not(.no-hover, .book-contents .entity-list-item):hover { - @include lightDark(background-color, #F2F2F2, #2d2d2d); + @include mixins.lightDark(background-color, #F2F2F2, #2d2d2d); border-radius: 0; } .card .entity-list-item .entity-list-item:hover { @@ -554,10 +557,10 @@ ul.pagination { } .entity-list-item-children { - padding: $-m $-l; + padding: vars.$m vars.$l; > div { overflow: hidden; - padding: 0 0 $-xs 0; + padding: 0 0 vars.$xs 0; } .entity-chip { text-overflow: ellipsis; @@ -568,7 +571,7 @@ ul.pagination { white-space: nowrap; } > .entity-list > .entity-list-item:last-child { - margin-bottom: -$-xs; + margin-bottom: -(vars.$xs); } } @@ -580,22 +583,22 @@ ul.pagination { background-position: 50% 50%; border-radius: 3px; position: relative; - margin-inline-end: $-l; + margin-inline-end: vars.$l; &.entity-list-item-image-wide { width: 220px; } .svg-icon { - @include lightDark(color, #fff, rgba(255, 255, 255, 0.6)); + @include mixins.lightDark(color, #fff, rgba(255, 255, 255, 0.6)); font-size: 1.66rem; margin-inline-end: 0; position: absolute; - bottom: $-xs; - left: $-xs; + bottom: vars.$xs; + left: vars.$xs; } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { width: 80px; } } @@ -605,7 +608,7 @@ ul.pagination { } .entity-list.compact { - font-size: 0.6 * $fs-m; + font-size: 0.6 * vars.$fs-m; h4, a { line-height: 1.2; } @@ -613,8 +616,8 @@ ul.pagination { display: none; } .entity-list-item p { - font-size: $fs-m * 0.8; - padding-top: $-xs; + font-size: vars.$fs-m * 0.8; + padding-top: vars.$xs; } .entity-list-item p:empty { padding-top: 0; @@ -624,12 +627,12 @@ ul.pagination { } > p.empty-text { display: block; - font-size: $fs-m; + font-size: vars.$fs-m; } hr { margin: 0; } - @include smaller-than($m) { + @include mixins.smaller-than(vars.$bp-m) { h4 { font-size: 1.666em; } @@ -660,13 +663,13 @@ ul.pagination { top: 0; list-style: none; inset-inline-end: 0; - margin: $-m 0; - @include lightDark(background-color, #fff, #333); + margin: vars.$m 0; + @include mixins.lightDark(background-color, #fff, #333); box-shadow: 0 1px 6px 0 rgba(0, 0, 0, 0.18); border-radius: 3px; min-width: 180px; - padding: $-xs 0; - @include lightDark(color, #555, #eee); + padding: vars.$xs 0; + @include mixins.lightDark(color, #555, #eee); fill: currentColor; text-align: start !important; max-height: 500px; @@ -697,11 +700,11 @@ ul.pagination { border-bottom: 1px solid #DDD; } li hr { - margin: $-xs 0; + margin: vars.$xs 0; } .icon-item, .text-item, .label-item { - padding: 8px $-m; - @include lightDark(color, #555, #eee); + padding: 8px vars.$m; + @include mixins.lightDark(color, #555, #eee); fill: currentColor; white-space: nowrap; line-height: 1.4; @@ -721,7 +724,7 @@ ul.pagination { outline-offset: -2px; } svg { - margin-inline-end: $-s; + margin-inline-end: vars.$s; display: inline-block; width: 16px; } @@ -733,7 +736,7 @@ ul.pagination { display: grid; align-items: center; grid-template-columns: auto min-content; - gap: $-m; + gap: vars.$m; } .label-item > *:nth-child(2) { opacity: 0.7; @@ -745,7 +748,7 @@ ul.pagination { display: grid; align-items: start; grid-template-columns: 16px auto; - gap: $-m; + gap: vars.$m; svg { margin-inline-end: 0; margin-block-start: 1px; @@ -757,7 +760,7 @@ ul.pagination { // being cut by scrollable container. .tri-layout-right .dropdown-menu, .tri-layout-left .dropdown-menu { - inset-inline-end: $-xs; + inset-inline-end: vars.$xs; } // Books grid view @@ -781,7 +784,7 @@ ul.pagination { .featured-image-container-wrap { position: relative; .svg-icon { - @include lightDark(color, #fff, rgba(255, 255, 255, 0.6)); + @include mixins.lightDark(color, #fff, rgba(255, 255, 255, 0.6)); font-size: 2rem; margin-inline-end: 0; position: absolute; @@ -800,30 +803,30 @@ ul.pagination { background: transparent; border: none; color: currentColor; - padding: $-m 0; + padding: vars.$m 0; } .active-link-list { a { display: inline-block; - padding: $-s; + padding: vars.$s; } a:not(.active) { - @include lightDark(color, #444, #888); + @include mixins.lightDark(color, #444, #888); } a:hover { - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); border-radius: 4px; text-decoration: none; } &.in-sidebar { a { display: block; - margin-bottom: $-xs; + margin-bottom: vars.$xs; } a.active { border-radius: 4px; - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } } } @@ -833,7 +836,7 @@ ul.pagination { line-height: 1.2; margin: 0.6em 0; align-content: start; - gap: $-s; + gap: vars.$s; a { line-height: 1.2; } diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 426f7961c72..17bcfcfbf11 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + .page-editor { display: flex; flex-direction: column; @@ -22,8 +25,8 @@ .page-editor-page-area { width: 100%; border-radius: 8px; - box-shadow: $bs-card; - @include lightDark(background-color, #FFF, #333) + box-shadow: vars.$bs-card; + @include mixins.lightDark(background-color, #FFF, #333) } .page-edit-toolbar { @@ -34,7 +37,7 @@ align-items: center; } -@include larger-than($xxl) { +@include mixins.larger-than(vars.$bp-xxl) { .page-editor-wysiwyg2024 .page-edit-toolbar, .page-editor-wysiwyg2024 .page-editor-page-area, .page-editor-wysiwyg .page-edit-toolbar, @@ -48,7 +51,7 @@ } } -@include smaller-than($m) { +@include mixins.smaller-than(vars.$bp-m) { .page-edit-toolbar { display: flex; flex-direction: row; @@ -76,7 +79,7 @@ position: relative; outline-offset: -1px; outline: 1px dashed var(--color-primary); - box-shadow: $bs-card; + box-shadow: vars.$bs-card; z-index: 50; } } @@ -96,7 +99,7 @@ body.tox-fullscreen, body.markdown-fullscreen { } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .page-edit-toolbar { overflow-x: scroll; overflow-y: visible; @@ -116,9 +119,9 @@ body.tox-fullscreen, body.markdown-fullscreen { width: 52px; height: 52px; font-size: 26px; - inset-inline-end: $-xs; - bottom: $-s; - box-shadow: $bs-hover; + inset-inline-end: vars.$xs; + bottom: vars.$s; + box-shadow: vars.$bs-hover; background-color: currentColor; text-align: center; svg { @@ -150,10 +153,10 @@ body.tox-fullscreen, body.markdown-fullscreen { } .pointer { border: 1px solid #CCC; - @include lightDark(border-color, #ccc, #000); + @include mixins.lightDark(border-color, #ccc, #000); border-radius: 4px; box-shadow: 0 0 12px 1px rgba(0, 0, 0, 0.1); - @include lightDark(background-color, #fff, #333); + @include mixins.lightDark(background-color, #fff, #333); width: 275px; &.is-page-editable { @@ -174,8 +177,8 @@ body.tox-fullscreen, body.markdown-fullscreen { border-right: 1px solid #CCC; border-bottom: 1px solid #CCC; z-index: 56; - @include lightDark(background-color, #fff, #333); - @include lightDark(border-color, #ccc, #000); + @include mixins.lightDark(background-color, #fff, #333); + @include mixins.lightDark(border-color, #ccc, #000); } input, button, a { position: relative; @@ -188,14 +191,14 @@ body.tox-fullscreen, body.markdown-fullscreen { input { background-color: #FFF; border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); color: #666; width: 160px; z-index: 40; padding: 5px 10px; } .text-button { - @include lightDark(color, #444, #AAA); + @include mixins.lightDark(color, #444, #AAA); } .input-group .button { line-height: 1; @@ -210,22 +213,22 @@ body.tox-fullscreen, body.markdown-fullscreen { height: 1.2em; } .button { - @include lightDark(border-color, #ddd, #000); + @include mixins.lightDark(border-color, #ddd, #000); } } // Page editor sidebar toolbox .floating-toolbox { - @include lightDark(background-color, #FFF, #222); + @include mixins.lightDark(background-color, #FFF, #222); overflow: hidden; align-items: stretch; flex-direction: row; display: flex; max-height: 100%; border-radius: 8px; - box-shadow: $bs-card; + box-shadow: vars.$bs-card; margin-bottom: auto; - margin-inline-start: $-l; + margin-inline-start: vars.$l; position: relative; &.open { position: relative; @@ -254,23 +257,23 @@ body.tox-fullscreen, body.markdown-fullscreen { } .tabs { border-inline-end: 1px solid #DDD; - @include lightDark(border-inline-end-color, #DDD, #000); + @include mixins.lightDark(border-inline-end-color, #DDD, #000); width: 40px; flex: 0 1 auto; margin-inline-end: -1px; } .tabs-inner { - @include lightDark(background-color, #FFFFFF, #222); + @include mixins.lightDark(background-color, #FFFFFF, #222); } .tabs svg { padding: 0; margin: 0; } .tabs-inner > button { - @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.5)); + @include mixins.lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.5)); display: block; cursor: pointer; - padding: 10px $-xs; + padding: 10px vars.$xs; font-size: 18px; line-height: 1.6; } @@ -295,8 +298,8 @@ body.tox-fullscreen, body.markdown-fullscreen { } h4 { font-size: 24px; - margin: $-m 0 0 0; - padding: 0 $-l $-s $-l; + margin: vars.$m 0 0 0; + padding: 0 vars.$l vars.$s vars.$l; } .tags input { max-width: 100%; @@ -304,8 +307,8 @@ body.tox-fullscreen, body.markdown-fullscreen { min-width: 50px; } .tags td, .inline-start-table > div > div > div { - padding-inline-end: $-s; - padding-top: $-s; + padding-inline-end: vars.$s; + padding-top: vars.$s; position: relative; } .handle { @@ -324,13 +327,13 @@ body.tox-fullscreen, body.markdown-fullscreen { } } -@include smaller-than($xxl) { +@include mixins.smaller-than(vars.$bp-xxl) { .floating-toolbox { - margin-inline-start: $-s; + margin-inline-start: vars.$s; } } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { .page-editor-page-area-wrap { margin: 4px !important; } @@ -429,7 +432,7 @@ body.tox-fullscreen, body.markdown-fullscreen { border-radius: 3px; position: relative; overflow: hidden; - padding: $-xs $-s; + padding: vars.$xs vars.$s; fill: currentColor; opacity: 0.85; transition: opacity ease-in-out 120ms; diff --git a/resources/sass/_spacing.scss b/resources/sass/_spacing.scss index 14f8918dcb4..6b5e2f837d9 100644 --- a/resources/sass/_spacing.scss +++ b/resources/sass/_spacing.scss @@ -1,8 +1,10 @@ +@use "vars"; + // Here we generate spacing utility classes for our sizes for all box sides and axis. // These will output to classes like .px-m (Padding on x-axis, medium size) or .mr-l (Margin right, large size) @mixin spacing($prop, $propLetter) { - @each $sizeLetter, $size in $spacing { + @each $sizeLetter, $size in vars.$spacing { .#{$propLetter}-#{$sizeLetter} { #{$prop}: $size !important; } @@ -31,7 +33,7 @@ @include spacing('margin', 'm'); @include spacing('padding', 'p'); -@each $sizeLetter, $size in $spacing { +@each $sizeLetter, $size in vars.$spacing { .gap-#{$sizeLetter} { gap: $size !important; } diff --git a/resources/sass/_tables.scss b/resources/sass/_tables.scss index a3da3362122..16be32fb39b 100644 --- a/resources/sass/_tables.scss +++ b/resources/sass/_tables.scss @@ -1,8 +1,11 @@ +@use "mixins"; +@use "vars"; + table { min-width: 100px; max-width: 100%; thead { - @include lightDark(background-color, #f8f8f8, #333); + @include mixins.lightDark(background-color, #f8f8f8, #333); font-weight: 500; } td, th { @@ -27,7 +30,7 @@ table.table { th, td { text-align: start; border: none; - padding: $-s $-s; + padding: vars.$s vars.$s; vertical-align: middle; margin: 0; overflow: visible; @@ -36,7 +39,7 @@ table.table { font-weight: bold; } tr:hover { - @include lightDark(background-color, #F2F2F2, #333); + @include mixins.lightDark(background-color, #F2F2F2, #333); } .text-right { text-align: end; @@ -51,10 +54,10 @@ table.table { display: inline-block; } &.expand-to-padding { - margin-left: -$-s; - margin-right: -$-s; - width: calc(100% + (2*#{$-s})); - max-width: calc(100% + (2*#{$-s})); + margin-left: -(vars.$s); + margin-right: -(vars.$s); + width: calc(100% + (2*#{vars.$s})); + max-width: calc(100% + (2*#{vars.$s})); } } @@ -66,10 +69,10 @@ table.no-style { } table.list-table { - margin: 0 (-$-xs); + margin: 0 (-(vars.$xs)); td { border: 0; vertical-align: middle; - padding: $-xs; + padding: vars.$xs; } } \ No newline at end of file diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 56e66195c38..04fd31e6df7 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + /** * Fonts */ @@ -43,7 +46,7 @@ h1, h2, h3, h4, h5, h6 { position: relative; display: block; font-family: var(--font-heading, var(--font-body)); - @include lightDark(color, #222, #BBB); + @include mixins.lightDark(color, #222, #BBB); } h5 { @@ -56,7 +59,7 @@ h5, h6 { margin-bottom: 0.66em; } -@include smaller-than($s) { +@include mixins.smaller-than(vars.$bp-s) { h1 { font-size: 2.8275em; } @@ -133,16 +136,16 @@ p, ul, ol, pre, table, blockquote { hr { border: 0; height: 1px; - @include lightDark(background, #eaeaea, #555); - margin-bottom: $-l; + @include mixins.lightDark(background, #eaeaea, #555); + margin-bottom: vars.$l; &.faded { background-image: linear-gradient(to right, #FFF, #e3e0e0 20%, #e3e0e0 80%, #FFF); } &.darker { - @include lightDark(background, #DDD, #666); + @include mixins.lightDark(background, #DDD, #666); } &.margin-top, &.even { - margin-top: $-l; + margin-top: vars.$l; } } @@ -174,8 +177,8 @@ sub, .subscript { pre { font-size: 12px; border: 1px solid #DDD; - @include lightDark(background-color, #FFF, #2B2B2B); - @include lightDark(border-color, #DDD, #111); + @include mixins.lightDark(background-color, #FFF, #2B2B2B); + @include mixins.lightDark(border-color, #DDD, #111); border-radius: 4px; padding-inline-start: 26px; position: relative; @@ -189,8 +192,8 @@ pre { width: 22.4px; inset-inline-start: 0; height: 100%; - @include lightDark(background-color, #f5f5f5, #313335); - @include lightDark(border-inline-end, 1px solid #DDD, none); + @include mixins.lightDark(background-color, #f5f5f5, #313335); + @include mixins.lightDark(border-inline-end, 1px solid #DDD, none); } } @@ -208,16 +211,16 @@ blockquote { position: relative; border-left: 4px solid transparent; border-left-color: var(--color-primary); - @include lightDark(background-color, #f8f8f8, #333); - padding: $-s $-m $-s $-xl; + @include mixins.lightDark(background-color, #f8f8f8, #333); + padding: vars.$s vars.$m vars.$s vars.$xl; overflow: auto; &:before { content: "\201C"; font-size: 2em; font-weight: bold; position: absolute; - top: $-s; - left: $-s; + top: vars.$s; + left: vars.$s; color: #777; } } @@ -238,8 +241,8 @@ blockquote { font-size: 0.84em; border: 1px solid #DDD; border-radius: 3px; - @include lightDark(background-color, #f8f8f8, #2b2b2b); - @include lightDark(border-color, #DDD, #444); + @include mixins.lightDark(background-color, #f8f8f8, #2b2b2b); + @include mixins.lightDark(border-color, #DDD, #444); } code { @@ -252,7 +255,7 @@ code { span.code { @extend .code-base; - padding: 1px $-xs; + padding: 1px vars.$xs; } pre code { @@ -272,8 +275,8 @@ span.highlight { * Lists */ ul, ol { - padding-left: $-m * 2.0; - padding-right: $-m * 2.0; + padding-left: vars.$m * 2.0; + padding-right: vars.$m * 2.0; display: flow-root; p { margin: 0; @@ -300,8 +303,8 @@ li > ol, li > ul { margin-block-start: 0; padding-block-end: 0; padding-block-start: 0; - padding-left: $-m * 1.2; - padding-right: $-m * 1.2; + padding-left: vars.$m * 1.2; + padding-right: vars.$m * 1.2; } /** @@ -312,17 +315,17 @@ li > ol, li > ul { li.checkbox-item, li.task-list-item { display: list-item; list-style: none; - margin-left: -($-m * 1.2); - margin-inline-start: -($-m * 1.2); + margin-left: -(vars.$m * 1.2); + margin-inline-start: -(vars.$m * 1.2); margin-inline-end: 0; input[type="checkbox"] { - margin-right: $-xs; - margin-inline-end: $-xs; + margin-right: vars.$xs; + margin-inline-end: vars.$xs; margin-inline-start: 0; } li.checkbox-item, li.task-list-item { - margin-left: $-xs; - margin-inline-start: $-xs; + margin-left: vars.$xs; + margin-inline-start: vars.$xs; margin-inline-end: 0; } } @@ -344,8 +347,8 @@ li.checkbox-item, li.task-list-item { text-align: end; } -@each $sizeLetter, $size in $screen-sizes { - @include larger-than($size) { +@each $sizeLetter, $size in vars.$screen-sizes { + @include mixins.larger-than($size) { .text-#{$sizeLetter}-center { text-align: center; } @@ -395,7 +398,7 @@ li.checkbox-item, li.task-list-item { * Grouping */ .header-group { - margin: $-m 0; + margin: vars.$m 0; h1, h2, h3, h4, h5, h6 { margin: 0; } @@ -403,7 +406,7 @@ li.checkbox-item, li.task-list-item { span.sep { color: #BBB; - padding: 0 $-xs; + padding: 0 vars.$xs; } .list > * { @@ -419,7 +422,7 @@ span.sep { display: inline-block; position: relative; bottom: -0.105em; - margin-inline-end: $-xs; + margin-inline-end: vars.$xs; pointer-events: none; fill: currentColor; } diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index 129e478212b..8cc96df4193 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -1,3 +1,6 @@ +@use "mixins"; +@use "vars"; + // Custom full screen mode .tox.tox-fullscreen { @@ -35,7 +38,7 @@ padding: 1rem; top: 4px; font-style: italic; - @include lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.5)) + @include mixins.lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.5)) } // Default styles for our custom root nodes @@ -74,23 +77,23 @@ body.page-content.mce-content-body { pointer-events: none; } .page-content.mce-content-body details doc-root { - padding: $-s; - margin-left: (2px - $-s); - margin-right: (2px - $-s); - margin-bottom: (2px - $-s); - margin-top: (2px - $-s); + padding: vars.$s; + margin-left: (2px - vars.$s); + margin-right: (2px - vars.$s); + margin-bottom: (2px - vars.$s); + margin-top: (2px - vars.$s); overflow: hidden; } // Allow alignment to be reflected in media embed wrappers .page-content.mce-content-body .mce-preview-object.align-right { float: right !important; - margin: $-xs 0 $-xs $-s; + margin: vars.$xs 0 vars.$xs vars.$s; } .page-content.mce-content-body .mce-preview-object.align-left { float: left !important; - margin: $-xs $-m $-m 0; + margin: vars.$xs vars.$m vars.$m 0; } .page-content.mce-content-body .mce-preview-object.align-center { diff --git a/resources/sass/_variables.scss b/resources/sass/_vars.scss similarity index 85% rename from resources/sass/_variables.scss rename to resources/sass/_vars.scss index 35586bf5830..26539656e7f 100644 --- a/resources/sass/_variables.scss +++ b/resources/sass/_vars.scss @@ -2,30 +2,30 @@ /////////////// // Screen breakpoints -$xxxl: 1700px; -$xxl: 1400px; -$xl: 1100px; -$l: 1000px; -$m: 880px; -$s: 600px; -$xs: 400px; -$xxs: 360px; +$bp-xxxl: 1700px; +$bp-xxl: 1400px; +$bp-xl: 1100px; +$bp-l: 1000px; +$bp-m: 880px; +$bp-s: 600px; +$bp-xs: 400px; +$bp-xxs: 360px; // List of screen sizes -$screen-sizes: (('xxs', $xxs), ('xs', $xs), ('s', $s), ('m', $m), ('l', $l), ('xl', $xl)); +$screen-sizes: (('xxs', $bp-xxs), ('xs', $bp-xs), ('s', $bp-s), ('m', $bp-m), ('l', $bp-l), ('xl', $bp-xl)); // Spacing (Margins+Padding) -$-xxxl: 64px; -$-xxl: 48px; -$-xl: 32px; -$-l: 24px; -$-m: 16px; -$-s: 12px; -$-xs: 6px; -$-xxs: 3px; +$xxxl: 64px; +$xxl: 48px; +$xl: 32px; +$l: 24px; +$m: 16px; +$s: 12px; +$xs: 6px; +$xxs: 3px; // List of our spacing sizes -$spacing: (('none', 0), ('xxs', $-xxs), ('xs', $-xs), ('s', $-s), ('m', $-m), ('l', $-l), ('xl', $-xl), ('xxl', $-xxl), ('auto', auto)); +$spacing: (('none', 0), ('xxs', $xxs), ('xs', $xs), ('s', $s), ('m', $m), ('l', $l), ('xl', $xl), ('xxl', $xxl), ('auto', auto)); // Fonts $font-body: -apple-system, BlinkMacSystemFont, diff --git a/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index cfa1ebdf8a6..8dd7be375e8 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -1,10 +1,11 @@ @use "sass:math"; -@import "variables"; -@import "mixins"; -@import "html"; -@import "text"; -@import "tables"; -@import "content"; + +@use "vars"; +@use "mixins"; +@use "html"; +@use "text"; +@use "tables"; +@use "content"; html, body { background-color: #FFF; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 2106f86e625..c6642d0cad6 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -1,31 +1,31 @@ -@use "sass:math"; +@use "sass:meta"; -@import "reset"; -@import "variables"; -@import "mixins"; -@import "spacing"; -@import "opacity"; -@import "html"; -@import "text"; -@import "colors"; -@import "layout"; -@import "blocks"; -@import "buttons"; -@import "tables"; -@import "forms"; -@import "animations"; -@import "tinymce"; -@import "editor"; -@import "codemirror"; -@import "components"; -@import "header"; -@import "footer"; -@import "lists"; -@import "pages"; -@import "content"; +@use "reset"; +@use "vars"; +@use "mixins"; +@use "spacing"; +@use "opacity"; +@use "html"; +@use "text"; +@use "colors"; +@use "layout"; +@use "blocks"; +@use "buttons"; +@use "tables"; +@use "forms"; +@use "animations"; +@use "tinymce"; +@use "editor"; +@use "codemirror"; +@use "components"; +@use "header"; +@use "footer"; +@use "lists"; +@use "pages"; +@use "content"; @media print { - @import "print"; + @include meta.load-css("print"); } // Jquery Sortable Styles @@ -41,7 +41,7 @@ body.dragging, body.dragging * { // User Avatar Images .avatar { border-radius: 100%; - @include lightDark(background-color, #eee, #000); + @include mixins.lightDark(background-color, #eee, #000); width: 30px; height: 30px; &.med { @@ -60,7 +60,7 @@ body.dragging, body.dragging * { border-radius: 3px; } &[src$="user_avatar.png"] { - @include whenDark { + @include mixins.whenDark { filter: invert(1); } } @@ -71,7 +71,7 @@ $loadingSize: 10px; .loading-container { position: relative; display: block; - margin: $-xl auto; + margin: vars.$xl auto; > div { width: $loadingSize; height: $loadingSize; @@ -79,7 +79,7 @@ $loadingSize: 10px; display: inline-block; vertical-align: top; transform: translate3d(-10px, 0, 0); - margin-top: $-xs; + margin-top: vars.$xs; animation-name: loadingBob; animation-duration: 1.4s; animation-iteration-count: infinite; @@ -89,17 +89,17 @@ $loadingSize: 10px; animation-delay: -300ms; } > div:first-child { - left: -($loadingSize+$-xs); + left: -($loadingSize+vars.$xs); background-color: var(--color-book); animation-delay: -600ms; } > div:last-of-type { - left: $loadingSize+$-xs; + left: $loadingSize+vars.$xs; background-color: var(--color-chapter); animation-delay: 0ms; } > span { - margin-inline-start: $-s; + margin-inline-start: vars.$s; font-style: italic; color: #888; vertical-align: top; @@ -107,7 +107,7 @@ $loadingSize: 10px; } .inline.block .loading-container { - margin: $-xs $-s; + margin: vars.$xs vars.$s; } .skip-to-content-link { @@ -118,10 +118,10 @@ $loadingSize: 10px; z-index: 15; border-radius: 0 4px 4px 0; display: block; - box-shadow: $bs-dark; + box-shadow: vars.$bs-dark; font-weight: bold; &:focus { - top: $-xl; + top: vars.$xl; outline-offset: -10px; outline: 2px dotted var(--color-link); } @@ -129,7 +129,7 @@ $loadingSize: 10px; .entity-selector { border: 1px solid #DDD; - @include lightDark(border-color, #ddd, #111); + @include mixins.lightDark(border-color, #ddd, #111); border-radius: 3px; overflow: hidden; font-size: 0.8em; @@ -140,7 +140,7 @@ $loadingSize: 10px; border: 0; border-bottom: 1px solid #DDD; font-size: 16px; - padding: $-s $-m; + padding: vars.$s vars.$m; } input[type="text"]:focus { outline: 1px solid var(--color-primary); @@ -150,12 +150,12 @@ $loadingSize: 10px; .entity-list { overflow-y: scroll; height: 400px; - @include lightDark(background-color, #eee, #222); + @include mixins.lightDark(background-color, #eee, #222); margin-inline-end: 0; margin-inline-start: 0; } .entity-list-item { - @include lightDark(background-color, #fff, #222); + @include mixins.lightDark(background-color, #fff, #222); } .entity-list-item p { margin-bottom: 0; @@ -165,11 +165,11 @@ $loadingSize: 10px; outline-offset: -4px; } .entity-list-item.selected { - @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); + @include mixins.lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } .loading { height: 400px; - padding-top: $-l; + padding-top: vars.$l; } &.compact { font-size: 10px; @@ -182,14 +182,14 @@ $loadingSize: 10px; } &.small { .entity-list-item { - padding: $-xs $-m; + padding: vars.$xs vars.$m; } .entity-list, .loading { height: 300px; } input[type="text"] { font-size: 13px; - padding: $-xs $-m; + padding: vars.$xs vars.$m; height: auto; } } @@ -207,11 +207,11 @@ $loadingSize: 10px; z-index: 150; } -@include between($s, $m) { +@include mixins.between(vars.$bp-s, vars.$bp-m) { #home-default > .grid.third { display: block; columns: 2; - column-gap: $-l !important; + column-gap: vars.$l !important; } } @@ -222,29 +222,29 @@ $loadingSize: 10px; } .list-sort { display: inline-grid; - margin-inline-start: $-s; + margin-inline-start: vars.$s; grid-template-columns: minmax(120px, max-content) 40px; font-size: 0.9rem; border: 2px solid #DDD; - @include lightDark(border-color, #ddd, #444); + @include mixins.lightDark(border-color, #ddd, #444); border-radius: 4px; } .list-sort-label { font-weight: bold; display: inline-block; - @include lightDark(color, #555, #888); + @include mixins.lightDark(color, #555, #888); } .list-sort-type { text-align: start; } .list-sort-type, .list-sort-dir { - padding: $-xs $-s; + padding: vars.$xs vars.$s; cursor: pointer; } .list-sort-dir { border-inline-start: 2px solid #DDD; color: #888; - @include lightDark(border-color, #ddd, #444); + @include mixins.lightDark(border-color, #ddd, #444); .svg-icon { transition: transform ease-in-out 120ms; } @@ -256,5 +256,5 @@ $loadingSize: 10px; .import-item { border-inline-start: 2px solid currentColor; - padding-inline-start: $-xs; + padding-inline-start: vars.$xs; } \ No newline at end of file From 5632fef6212001ec8357c854f565b5e6a1df9c0f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 14:22:48 +0000 Subject: [PATCH 66/72] Auth: Added specific guards against guest account login Hardened things to enforce the intent that the guest account should not be used for logins. Currently this would not be allowed due to empty set password, and no password fields on user edit forms, but an error could occur if the login was attempted. This adds: - Handling to show normal invalid user warning on login instead of a hash check error. - Prevention of guest user via main login route, in the event that inventive workarounds would be used by admins to set a password for this account. - Test for guest user login. --- app/Access/LoginService.php | 35 +++++++++++++++++-- .../LoginAttemptInvalidUserException.php | 7 ++++ tests/Auth/AuthTest.php | 20 +++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 app/Exceptions/LoginAttemptInvalidUserException.php diff --git a/app/Access/LoginService.php b/app/Access/LoginService.php index cc48e0f9bab..6607969afae 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -5,6 +5,7 @@ use BookStack\Access\Mfa\MfaSession; use BookStack\Activity\ActivityType; use BookStack\Exceptions\LoginAttemptException; +use BookStack\Exceptions\LoginAttemptInvalidUserException; use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Facades\Activity; use BookStack\Facades\Theme; @@ -29,10 +30,14 @@ public function __construct( * a reason to (MFA or Unconfirmed Email). * Returns a boolean to indicate the current login result. * - * @throws StoppedAuthenticationException + * @throws StoppedAuthenticationException|LoginAttemptInvalidUserException */ public function login(User $user, string $method, bool $remember = false): void { + if ($user->isGuest()) { + throw new LoginAttemptInvalidUserException('Login not allowed for guest user'); + } + if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) { $this->setLastLoginAttemptedForUser($user, $method, $remember); @@ -58,7 +63,7 @@ public function login(User $user, string $method, bool $remember = false): void * * @throws Exception */ - public function reattemptLoginFor(User $user) + public function reattemptLoginFor(User $user): void { if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) { throw new Exception('Login reattempt user does align with current session state'); @@ -152,16 +157,40 @@ public function awaitingEmailConfirmation(User $user): bool */ public function attempt(array $credentials, string $method, bool $remember = false): bool { + if ($this->areCredentialsForGuest($credentials)) { + return false; + } + $result = auth()->attempt($credentials, $remember); if ($result) { $user = auth()->user(); auth()->logout(); - $this->login($user, $method, $remember); + try { + $this->login($user, $method, $remember); + } catch (LoginAttemptInvalidUserException $e) { + // Catch and return false for non-login accounts + // so it looks like a normal invalid login. + return false; + } } return $result; } + /** + * Check if the given credentials are likely for the system guest account. + */ + protected function areCredentialsForGuest(array $credentials): bool + { + if (isset($credentials['email'])) { + return User::query()->where('email', '=', $credentials['email']) + ->where('system_name', '=', 'public') + ->exists(); + } + + return false; + } + /** * Logs the current user out of the application. * Returns an app post-redirect path. diff --git a/app/Exceptions/LoginAttemptInvalidUserException.php b/app/Exceptions/LoginAttemptInvalidUserException.php new file mode 100644 index 00000000000..163484c5aa2 --- /dev/null +++ b/app/Exceptions/LoginAttemptInvalidUserException.php @@ -0,0 +1,7 @@ +assertSee('Too many login attempts. Please try again in'); } + public function test_login_specifically_disabled_for_guest_account() + { + $guest = $this->users->guest(); + + $resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']); + $resp->assertRedirect('/login'); + $resp = $this->followRedirects($resp); + $resp->assertSee('These credentials do not match our records.'); + + // Test login even with password somehow set + $guest->password = Hash::make('password'); + $guest->save(); + + $resp = $this->post('/login', ['email' => $guest->email, 'password' => 'password']); + $resp->assertRedirect('/login'); + $resp = $this->followRedirects($resp); + $resp->assertSee('These credentials do not match our records.'); + } + /** * Perform a login. */ From 509af2463d94210fd500c10c651fa8f883e87fd8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 15:53:57 +0000 Subject: [PATCH 67/72] Search Index: Fixed SQL error when indexing large pages Due to hitting statement placeholder limits (typically 65k) when inserting index terms for single page. Added test to cover. Also added skipped tests for tests we don't always want to run. For #5322 --- app/Search/SearchIndex.php | 20 +++++++++++++++----- tests/Entity/EntitySearchTest.php | 20 ++++++++++++++++++++ tests/LanguageTest.php | 4 +++- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/app/Search/SearchIndex.php b/app/Search/SearchIndex.php index d9fc4e7aadc..c7d9d6502e2 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -30,7 +30,7 @@ public function indexEntity(Entity $entity): void { $this->deleteEntityTerms($entity); $terms = $this->entityToTermDataArray($entity); - SearchTerm::query()->insert($terms); + $this->insertTerms($terms); } /** @@ -46,10 +46,7 @@ public function indexEntities(array $entities): void array_push($terms, ...$entityTerms); } - $chunkedTerms = array_chunk($terms, 500); - foreach ($chunkedTerms as $termChunk) { - SearchTerm::query()->insert($termChunk); - } + $this->insertTerms($terms); } /** @@ -99,6 +96,19 @@ public function deleteEntityTerms(Entity $entity): void $entity->searchTerms()->delete(); } + /** + * Insert the given terms into the database. + * Chunks through the given terms to remain within database limits. + * @param array[] $terms + */ + protected function insertTerms(array $terms): void + { + $chunkedTerms = array_chunk($terms, 500); + foreach ($chunkedTerms as $termChunk) { + SearchTerm::query()->insert($termChunk); + } + } + /** * Create a scored term array from the given text, where the keys are the terms * and the values are their scores. diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index cabf23bd3d3..5ace70e3ab2 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use Illuminate\Support\Str; use Tests\TestCase; class EntitySearchTest extends TestCase @@ -477,6 +478,25 @@ public function test_terms_in_headers_have_an_adjusted_index_score() $this->assertEquals(2, $scoreByTerm->get('TermG')); } + public function test_indexing_works_as_expected_for_page_with_lots_of_terms() + { + $this->markTestSkipped('Time consuming test'); + + $count = 100000; + $text = ''; + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_#'; + for ($i = 0; $i < $count; $i++) { + $text .= substr(str_shuffle($chars), 0, 5) . ' '; + } + + $page = $this->entities->newPage(['name' => 'Test page A', 'html' => '

    ' . $text . '

    ']); + + $termCount = $page->searchTerms()->count(); + + // Expect at least 90% unique rate + $this->assertGreaterThan($count * 0.9, $termCount); + } + public function test_name_and_content_terms_are_merged_to_single_score() { $page = $this->entities->newPage(['name' => 'TermA', 'html' => ' diff --git a/tests/LanguageTest.php b/tests/LanguageTest.php index abe06407e1e..28491c3af9d 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -27,8 +27,10 @@ public function test_locales_list_set_properly() } // Not part of standard phpunit test runs since we sometimes expect non-added langs. - public function do_test_locales_all_have_language_dropdown_entry() + public function test_locales_all_have_language_dropdown_entry() { + $this->markTestSkipped('Only used when checking language inclusion'); + $dropdownLocales = array_keys(trans('settings.language_select', [], 'en')); sort($dropdownLocales); sort($this->langs); From 0ece664475180119b6e5c6c589302628c080cc71 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 18:50:10 +0000 Subject: [PATCH 68/72] CI: Added php8.4 to CI suites, bumped action/os versions --- .github/workflows/analyse-php.yml | 4 ++-- .github/workflows/lint-php.yml | 6 +++--- .github/workflows/test-migrations.yml | 6 +++--- .github/workflows/test-php.yml | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/analyse-php.yml b/.github/workflows/analyse-php.yml index dbeaf94724a..647835aeb2f 100644 --- a/.github/workflows/analyse-php.yml +++ b/.github/workflows/analyse-php.yml @@ -11,9 +11,9 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/lint-php.yml b/.github/workflows/lint-php.yml index b2f8b3d2b4a..cb9dedcb25a 100644 --- a/.github/workflows/lint-php.yml +++ b/.github/workflows/lint-php.yml @@ -11,14 +11,14 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.3 tools: phpcs - name: Run formatting check diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml index 10cfbe17204..2d6d280b2ee 100644 --- a/.github/workflows/test-migrations.yml +++ b/.github/workflows/test-migrations.yml @@ -13,12 +13,12 @@ on: jobs: build: if: ${{ github.ref != 'refs/heads/l10n_development' }} - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml index 269f326320a..ee9cf39bc2a 100644 --- a/.github/workflows/test-php.yml +++ b/.github/workflows/test-php.yml @@ -16,9 +16,9 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 From fcf0bf79a98c9b9b545d36ef728ee9cbe054048e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 11 Dec 2024 20:38:30 +0000 Subject: [PATCH 69/72] Attachments: Hid edit/delete controls where lacking permission Added test to cover. Also migrated related ajax-delete-row component to ts. For #5323 --- ...{ajax-delete-row.js => ajax-delete-row.ts} | 12 +++-- resources/js/components/component.js | 6 +-- .../views/attachments/manager-list.blade.php | 34 +++++++------- tests/Uploads/AttachmentTest.php | 44 +++++++++++++++++++ 4 files changed, 74 insertions(+), 22 deletions(-) rename resources/js/components/{ajax-delete-row.js => ajax-delete-row.ts} (65%) diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.ts similarity index 65% rename from resources/js/components/ajax-delete-row.js rename to resources/js/components/ajax-delete-row.ts index 6ed3deedf4d..4c7942a9e36 100644 --- a/resources/js/components/ajax-delete-row.js +++ b/resources/js/components/ajax-delete-row.ts @@ -1,12 +1,16 @@ -import {onSelect} from '../services/dom.ts'; +import {onSelect} from '../services/dom'; import {Component} from './component'; export class AjaxDeleteRow extends Component { + protected row!: HTMLElement; + protected url!: string; + protected deleteButtons: HTMLElement[] = []; + setup() { this.row = this.$el; this.url = this.$opts.url; - this.deleteButtons = this.$manyRefs.delete; + this.deleteButtons = this.$manyRefs.delete || []; onSelect(this.deleteButtons, this.runDelete.bind(this)); } @@ -21,8 +25,8 @@ export class AjaxDeleteRow extends Component { } this.row.remove(); }).catch(() => { - this.row.style.opacity = null; - this.row.style.pointerEvents = null; + this.row.style.removeProperty('opacity'); + this.row.style.removeProperty('pointer-events'); }); } diff --git a/resources/js/components/component.js b/resources/js/components/component.js index 654f41a9664..c23898bbcbb 100644 --- a/resources/js/components/component.js +++ b/resources/js/components/component.js @@ -8,20 +8,20 @@ export class Component { /** * The element that the component is registered upon. - * @type {Element} + * @type {HTMLElement} */ $el = null; /** * Mapping of referenced elements within the component. - * @type {Object} + * @type {Object} */ $refs = {}; /** * Mapping of arrays of referenced elements within the component so multiple * references, sharing the same name, can be fetched. - * @type {Object} + * @type {Object} */ $manyRefs = {}; diff --git a/resources/views/attachments/manager-list.blade.php b/resources/views/attachments/manager-list.blade.php index 0e841a042f7..6314aa7b5d7 100644 --- a/resources/views/attachments/manager-list.blade.php +++ b/resources/views/attachments/manager-list.blade.php @@ -15,23 +15,27 @@ class="card drag-card"> option:event-emit-select:name="insert" type="button" title="{{ trans('entities.attachments_insert_link') }}" - class="drag-card-action text-center text-link">@icon('link') - -
    - + @if(userCan('attachment-update', $attachment)) + - + @endif
    @endforeach diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index de448d93a4c..2eaf21d9c6f 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -267,6 +267,50 @@ public function test_data_and_js_links_cannot_be_attached_to_a_page() } } + public function test_attachment_delete_only_shows_with_permission() + { + $this->asAdmin(); + $page = $this->entities->page(); + $this->files->uploadAttachmentFile($this, 'upload_test.txt', $page->id); + $attachment = $page->attachments()->first(); + $viewer = $this->users->viewer(); + + $this->permissions->grantUserRolePermissions($viewer, ['page-update-all', 'attachment-create-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"]"); + $html->assertElementNotExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Delete\"]"); + + $this->permissions->grantUserRolePermissions($viewer, ['attachment-delete-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Delete\"]"); + } + + public function test_attachment_edit_only_shows_with_permission() + { + $this->asAdmin(); + $page = $this->entities->page(); + $this->files->uploadAttachmentFile($this, 'upload_test.txt', $page->id); + $attachment = $page->attachments()->first(); + $viewer = $this->users->viewer(); + + $this->permissions->grantUserRolePermissions($viewer, ['page-update-all', 'attachment-create-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"]"); + $html->assertElementNotExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Edit\"]"); + + $this->permissions->grantUserRolePermissions($viewer, ['attachment-update-all']); + + $resp = $this->actingAs($viewer)->get($page->getUrl('/edit')); + $html = $this->withHtml($resp); + $html->assertElementExists(".card[data-id=\"{$attachment->id}\"] button[title=\"Edit\"]"); + } + public function test_file_access_with_open_query_param_provides_inline_response_with_correct_content_type() { $page = $this->entities->page(); From 19ee1c9be740de037dbe61ec8e82e74f24276322 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 12 Dec 2024 21:45:52 +0000 Subject: [PATCH 70/72] Notifications: Logged errors and prevented them blocking user Failed notification sends could block the user action, whereas it's probably more important that the user action takes places uninteruupted than showing an error screen for the user to debug. Logs notification errors so issues can still be debugged by admins. Closes #5315 --- .../Handlers/BaseNotificationHandler.php | 7 +++++- tests/Activity/WatchTest.php | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php index b5f339b2ce0..3a9b0c1dc80 100644 --- a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Log; abstract class BaseNotificationHandler implements NotificationHandler { @@ -36,7 +37,11 @@ protected function sendNotificationToUserIds(string $notification, array $userId } // Send the notification - $user->notify(new $notification($detail, $initiator)); + try { + $user->notify(new $notification($detail, $initiator)); + } catch (\Exception $exception) { + Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}"); + } } } } diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index 605b60fd498..c405b07aed7 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -13,6 +13,8 @@ use BookStack\Activity\WatchLevels; use BookStack\Entities\Models\Entity; use BookStack\Settings\UserNotificationPreferences; +use Illuminate\Contracts\Notifications\Dispatcher; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Tests\TestCase; @@ -365,6 +367,29 @@ public function test_notifications_sent_in_right_language() } } + public function test_failed_notifications_dont_block_and_log_errors() + { + $logger = $this->withTestLogger(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $page = $this->entities->page(); + $book = $page->book; + $activityLogger = app()->make(ActivityLogger::class); + + $watches = new UserEntityWatchOptions($editor, $book); + $watches->updateLevelByValue(WatchLevels::UPDATES); + + $mockDispatcher = $this->mock(Dispatcher::class); + $mockDispatcher->shouldReceive('send')->once() + ->andThrow(\Exception::class, 'Failed to connect to mail server'); + + $this->actingAs($admin); + + $activityLogger->add(ActivityType::PAGE_UPDATE, $page); + + $this->assertTrue($logger->hasErrorThatContains("Failed to send email notification to user [id:{$editor->id}] with error: Failed to connect to mail server")); + } + public function test_notifications_not_sent_if_lacking_view_permission_for_related_item() { $notifications = Notification::fake(); From 7e1a8e5ec676a2f9a27acef71da6d6c0b5916384 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Dec 2024 14:21:04 +0000 Subject: [PATCH 71/72] API: Added cover to book/shelf list endpoints Aligns with what we provide in the UI. Added/updated tests to cover, and updated API examples. For 5180. --- .../Controllers/BookApiController.php | 1 + .../Controllers/BookshelfApiController.php | 1 + dev/api/responses/books-list.json | 10 ++++++-- dev/api/responses/shelves-list.json | 13 ++++++++--- tests/Api/BooksApiTest.php | 23 +++++++++++++++++++ tests/Api/ShelvesApiTest.php | 23 +++++++++++++++++++ tests/Helpers/EntityProvider.php | 1 + 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index c1e38e72fe7..a617ee2da68 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -30,6 +30,7 @@ public function list() { $books = $this->queries ->visibleForList() + ->with(['cover:id,name,url']) ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($books, [ diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index a665bcb6bab..b512f2d0553 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -26,6 +26,7 @@ public function list() { $shelves = $this->queries ->visibleForList() + ->with(['cover:id,name,url']) ->addSelect(['created_by', 'updated_by']); return $this->apiListingResponse($shelves, [ diff --git a/dev/api/responses/books-list.json b/dev/api/responses/books-list.json index 0f8458feda2..50c8c49e6ef 100644 --- a/dev/api/responses/books-list.json +++ b/dev/api/responses/books-list.json @@ -9,7 +9,8 @@ "updated_at": "2019-12-11T20:57:31.000000Z", "created_by": 1, "updated_by": 1, - "owned_by": 1 + "owned_by": 1, + "cover": null }, { "id": 2, @@ -20,7 +21,12 @@ "updated_at": "2019-12-11T20:57:23.000000Z", "created_by": 4, "updated_by": 3, - "owned_by": 3 + "owned_by": 3, + "cover": { + "id": 11, + "name": "cat_banner.jpg", + "url": "https://example.com/uploads/images/cover_book/2021-10/cat-banner.jpg" + } } ], "total": 14 diff --git a/dev/api/responses/shelves-list.json b/dev/api/responses/shelves-list.json index 4b1a1b43f93..d5debfaef10 100644 --- a/dev/api/responses/shelves-list.json +++ b/dev/api/responses/shelves-list.json @@ -9,7 +9,12 @@ "updated_at": "2020-04-10T13:00:45.000000Z", "created_by": 4, "updated_by": 1, - "owned_by": 1 + "owned_by": 1, + "cover": { + "id": 4, + "name": "shelf.jpg", + "url": "https://example.com/uploads/images/cover_bookshelf/2024-12/shelf.jpg" + } }, { "id": 9, @@ -20,7 +25,8 @@ "updated_at": "2020-04-10T13:00:58.000000Z", "created_by": 4, "updated_by": 1, - "owned_by": 1 + "owned_by": 1, + "cover": null }, { "id": 10, @@ -31,7 +37,8 @@ "updated_at": "2020-04-10T13:00:53.000000Z", "created_by": 4, "updated_by": 1, - "owned_by": 4 + "owned_by": 4, + "cover": null } ], "total": 3 diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 0de98dc323b..084cb59bd5c 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -3,6 +3,7 @@ namespace Tests\Api; use BookStack\Entities\Models\Book; +use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -27,6 +28,28 @@ public function test_index_endpoint_returns_expected_book() 'owned_by' => $firstBook->owned_by, 'created_by' => $firstBook->created_by, 'updated_by' => $firstBook->updated_by, + 'cover' => null, + ], + ]]); + } + + public function test_index_endpoint_includes_cover_if_set() + { + $this->actingAsApiEditor(); + $book = $this->entities->book(); + + $baseRepo = $this->app->make(BaseRepo::class); + $image = $this->files->uploadedImage('book_cover'); + $baseRepo->updateCoverImage($book, $image); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $book->id); + $resp->assertJson(['data' => [ + [ + 'id' => $book->id, + 'cover' => [ + 'id' => $book->cover->id, + 'url' => $book->cover->url, + ], ], ]]); } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index be276e11004..ba13c0153b1 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Repos\BaseRepo; use Carbon\Carbon; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -28,6 +29,28 @@ public function test_index_endpoint_returns_expected_shelf() 'owned_by' => $firstBookshelf->owned_by, 'created_by' => $firstBookshelf->created_by, 'updated_by' => $firstBookshelf->updated_by, + 'cover' => null, + ], + ]]); + } + + public function test_index_endpoint_includes_cover_if_set() + { + $this->actingAsApiEditor(); + $shelf = $this->entities->shelf(); + + $baseRepo = $this->app->make(BaseRepo::class); + $image = $this->files->uploadedImage('shelf_cover'); + $baseRepo->updateCoverImage($shelf, $image); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $shelf->id); + $resp->assertJson(['data' => [ + [ + 'id' => $shelf->id, + 'cover' => [ + 'id' => $shelf->cover->id, + 'url' => $shelf->cover->url, + ], ], ]]); } diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index 1897abefa28..22e554f74b5 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookshelfRepo; From a8ef820443cc444423f0fd21c4e1ce9fb64cbac0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 13 Dec 2024 15:19:09 +0000 Subject: [PATCH 72/72] Users: Hid lanuage preference for guest user Hiding since it's not really used, and may mislead on how to set default app language (which should be done via env options). Updated test to cover. For #5356 --- resources/views/users/edit.blade.php | 4 +++- tests/User/UserManagementTest.php | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 2b736d81ede..611653d6a80 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -34,7 +34,9 @@ class="setting-list-label">{{ trans('settings.users_avatar') }}
    - @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) + @if(!$user->isGuest()) + @include('users.parts.language-option-row', ['value' => old('language') ?? $user->getLocale()->appLocale()]) + @endif
    diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index 37d9b383544..d92f13f0b3f 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -202,9 +202,13 @@ public function test_delete_removes_user_preferences() public function test_guest_profile_shows_limited_form() { $guest = $this->users->guest(); + $resp = $this->asAdmin()->get('/settings/users/' . $guest->id); $resp->assertSee('Guest'); - $this->withHtml($resp)->assertElementNotExists('#password'); + $html = $this->withHtml($resp); + + $html->assertElementNotExists('#password'); + $html->assertElementNotExists('[name="language"]'); } public function test_guest_profile_cannot_be_deleted()