From 2e8d6ce7d9aad7569cd8bfbdd1126c869a78d379 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 10 Oct 2024 12:03:24 +0100 Subject: [PATCH 01/89] 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 3213c4835..488654279 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 2eede241c..07711312f 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 2344619f5..787e11c87 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 798bd7aac..44c0d02f9 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 1e13ae388..ff9d68c7a 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 216067552..9450444ca 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 a6332cbb8..664767605 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 1264d1058..7f684dd42 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 342cac0af..197c50b0e 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/89] 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 5f4902f86..000000000 --- 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 000000000..141a50ef5 --- /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 7c6480a1a..6b0707bdd 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 6f740ed71..7b6fa79fb 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 787e11c87..fcbabc022 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 0d2018b9d..29173a058 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 662736548..edd428037 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 e505c96e0..b637c97c1 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 bc983c807..adf4cb3c9 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 7f684dd42..c5a5d2db8 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/89] 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 fea8c01e3..cd8bf279f 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 488654279..e7de15ae5 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 aa2801f19..6ed3deedf 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 583dde572..de1a6db43 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 f45b25e36..2dc7313a8 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 07711312f..0b828e71b 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 2ba7d5d36..48557141f 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 091c3483f..12937d472 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 184618fcc..00f3cfed2 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 4efd428ac..5dd5dd93b 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 920fe875f..598e0d8d4 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 7ab99a2a7..b020c5d85 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 7a5044470..9d4513326 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 561370d7a..7491119a1 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 2097c0528..f722a25e7 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 44c0d02f9..2cdaf591a 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 47231477b..c8108ab28 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 64cee12cd..1b133047d 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 fd8ad1f2e..8c0a8b33e 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 1d6abfe20..3d7e1365f 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 ff9d68c7a..d3ac78a4a 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 9450444ca..7ffceb0f9 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 607576cb9..292b923e5 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 edd428037..6bd8f9c72 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 56ec876d4..cf81990ab 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 e6adc3c23..f9ec03ed3 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 bcfd0b565..c88827bac 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 34111bb2d..13fbdfecc 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/89] 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 000000000..260735c58 --- /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/89] 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 260735c58..c4737309f 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/89] 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 c4737309f..dc21bf8e5 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/89] 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 | 28 ++++++----- routes/api.php | 26 +++++----- routes/web.php | 27 ++++++----- tests/Entity/ExportTest.php | 2 +- 15 files changed, 118 insertions(+), 53 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 000000000..b2c811e0b --- /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 5c54774f1..3680a2c6a 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 dc21bf8e5..d5635bd39 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]]` +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: -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). +``` +[[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 c0919d324..710364855 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::get('books/{id}', [EntityControllers\BookApiController::class, 'read']); 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::get('pages/{id}', [EntityControllers\PageApiController::class, 'read']); 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 81b938f32..5220684c0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,6 +7,7 @@ use BookStack\Api\UserApiTokenController; 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::middleware('auth')->group(function () { 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::middleware('auth')->group(function () { 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::middleware('auth')->group(function () { 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 7aafa3b79..11cfddb20 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -5,8 +5,8 @@ namespace Tests\Entity; 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/89] 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 11cfddb20..000000000 --- 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 000000000..77b26ad89 --- /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 000000000..069cf2801 --- /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 000000000..05ebbc68d --- /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 000000000..9d85c69e2 --- /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 000000000..c593a6585 --- /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/89] 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 a4e7aae87..01611fd21 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -6,6 +6,7 @@ use BookStack\Entities\Queries\PageQueries; 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 @@ class PageExportController extends Controller 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 d1a7b6bd4..2b8b45d0d 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, + ]; + } + + protected function convertAttachment(Attachment $attachment): array + { + $data = [ + 'name' => $attachment->name, + 'order' => $attachment->order, ]; - return $this->build(); + 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 @@ class ZipExportBuilder { $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 @@ class ZipExportBuilder $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 000000000..d3ee70e93 --- /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 bd319fbd7..227649d8f 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -13,14 +13,9 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; 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 35e6f050b..7e5a708ef 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -39,6 +39,7 @@ return [ '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 a55ab56d1..e58c842ba 100644 --- a/resources/views/entities/export-menu.blade.php +++ b/resources/views/entities/export-menu.blade.php @@ -18,6 +18,7 @@
  • {{ 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 5220684c0..6ae70983d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -91,6 +91,7 @@ Route::middleware('auth')->group(function () { 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 000000000..d8ce00be3 --- /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/89] ZIP Export: Started building link/ref handling --- app/Exports/ZipExportBuilder.php | 56 +++----------- .../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, 266 insertions(+), 46 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 2b8b45d0d..720b4997d 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,57 +22,21 @@ class ZipExportBuilder */ public function buildForPage(Page $page): string { - $this->data['page'] = $this->convertPage($page); + $exportPage = ZipExportPage::fromModel($page, $this->files); + $this->data['page'] = $exportPage; + + $this->references->addPage($exportPage); + 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, - ]; - } - - 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, - ]; - } - /** * @throws ZipExportException */ 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 000000000..d6d674a91 --- /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 000000000..73fe3bbf5 --- /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 000000000..636c9ff6d --- /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 000000000..89deb7eda --- /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 000000000..6ca826bc3 --- /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 d5635bd39..7a99563d1 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/89] 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 436d66d59..23fa8e6ea 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 @@ class ApiEntityListFormatter 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 d1619e118..5072bd3b4 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 @@ class SearchApiController extends ApiController * 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 @@ class SearchApiController extends ApiController $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 ee5223816..7fa1a304e 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 2c7584e3f..bb45b7959 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 2a186e8d6..b80ed4530 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 @@ class SearchApiTest extends TestCase $resp = $this->actingAsApiAdmin()->getJson($this->baseEndpoint . '?query=superuniquevalue'); $resp->assertJsonFragment([ 'type' => 'page', - 'url' => $page->getUrl(), + 'url' => $page->getUrl(), ]); } @@ -57,10 +58,10 @@ class SearchApiTest extends TestCase $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 @@ class SearchApiTest extends TestCase $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/89] 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 23fa8e6ea..2fd9b7c55 100644 --- a/app/Api/ApiEntityListFormatter.php +++ b/app/Api/ApiEntityListFormatter.php @@ -106,6 +106,10 @@ class ApiEntityListFormatter */ public function format(): array { + if ($this->includeRelatedTitles) { + $this->loadRelatedTitles(); + } + $results = []; foreach ($this->list as $item) { @@ -115,6 +119,23 @@ class ApiEntityListFormatter 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/89] 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 d6d674a91..d79a16cc1 100644 --- a/app/Exports/ZipExportModels/ZipExportAttachment.php +++ b/app/Exports/ZipExportModels/ZipExportAttachment.php @@ -5,7 +5,7 @@ namespace BookStack\Exports\ZipExportModels; 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 73fe3bbf5..540d3d4e5 100644 --- a/app/Exports/ZipExportModels/ZipExportImage.php +++ b/app/Exports/ZipExportModels/ZipExportImage.php @@ -4,7 +4,7 @@ namespace BookStack\Exports\ZipExportModels; 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 e1cb616de..26b994c01 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 6589ce60a..c7a950354 100644 --- a/app/Exports/ZipExportModels/ZipExportPage.php +++ b/app/Exports/ZipExportModels/ZipExportPage.php @@ -6,7 +6,7 @@ use BookStack\Entities\Models\Page; 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 636c9ff6d..09ae9f06c 100644 --- a/app/Exports/ZipExportModels/ZipExportTag.php +++ b/app/Exports/ZipExportModels/ZipExportTag.php @@ -4,7 +4,7 @@ namespace BookStack\Exports\ZipExportModels; 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 89deb7eda..76a7fedbe 100644 --- a/app/Exports/ZipExportReferences.php +++ b/app/Exports/ZipExportReferences.php @@ -44,11 +44,14 @@ class ZipExportReferences // 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 6ca826bc3..820920da2 100644 --- a/app/Exports/ZipReferenceParser.php +++ b/app/Exports/ZipReferenceParser.php @@ -4,9 +4,11 @@ namespace BookStack\Exports; 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 @@ class ZipReferenceParser 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 000000000..e870d515b --- /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 000000000..331dd593b --- /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/89] 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 720b4997d..5c56e531b 100644 --- a/app/Exports/ZipExportBuilder.php +++ b/app/Exports/ZipExportBuilder.php @@ -35,7 +35,7 @@ class ZipExportBuilder */ 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 d3ee70e93..27b6f937a 100644 --- a/app/Exports/ZipExportFiles.php +++ b/app/Exports/ZipExportFiles.php @@ -4,6 +4,8 @@ namespace BookStack\Exports; 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 @@ class ZipExportFiles 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 @@ class ZipExportFiles 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 540d3d4e5..39f1d1012 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 76a7fedbe..19672db0a 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 @@ class ZipExportReferences } } - 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 @@ class ZipExportReferences // 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 8d8da61ec..e501cc7b1 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -133,6 +133,19 @@ class ImageService 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 798b72abd..8df702e0d 100644 --- a/app/Uploads/ImageStorageDisk.php +++ b/app/Uploads/ImageStorageDisk.php @@ -55,6 +55,15 @@ class ImageStorageDisk 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 7a99563d1..1ba587201 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/89] 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 01611fd21..34e67ffcf 100644 --- a/app/Exports/Controllers/PageExportController.php +++ b/app/Exports/Controllers/PageExportController.php @@ -6,7 +6,7 @@ use BookStack\Entities\Queries\PageQueries; 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 d79a16cc1..8c89ae11f 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 820920da2..4d16dbc61 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/89] 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 c926aaa64..088d199da 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -60,6 +60,7 @@ class Chapter extends BookChild /** * 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 000000000..5a0c5806b --- /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 000000000..cd5765f48 --- /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 bae46ca82..8075595f2 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -26,6 +26,7 @@ class ZipExportPage extends ZipExportModel $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 @@ class ZipExportPage extends ZipExportModel 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 15edebea5..42fb03541 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 @@ class ZipExportBuilder 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 c3565aaa3..1fce0fc97 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -4,6 +4,8 @@ namespace BookStack\Exports\ZipExports; 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 @@ class ZipExportReferences } } + 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 { + $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 = function (Model $model) use ($files, $page) { - return $this->handleModelReference($model, $page, $files); - }; - + $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 1ba587201..6cee7356d 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/89] 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 36906b6ad..f726175a0 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 @@ class BookExportController extends Controller 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 d85b90dcb..0d7a5c0d1 100644 --- a/app/Exports/Controllers/ChapterExportController.php +++ b/app/Exports/Controllers/ChapterExportController.php @@ -5,6 +5,7 @@ namespace BookStack\Exports\Controllers; 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 @@ class ChapterExportController extends Controller 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 1fce0fc97..8b3a4b612 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 @@ class ZipExportReferences 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 @@ class ZipExportReferences 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 6ae70983d..e6f3683c6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -132,6 +132,7 @@ Route::middleware('auth')->group(function () { 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 d8ce00be3..536e23806 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 000000000..b5cc2b4ca --- /dev/null +++ b/tests/Exports/ZipResultData.php @@ -0,0 +1,13 @@ + Date: Sun, 27 Oct 2024 14:33:43 +0000 Subject: [PATCH 18/89] 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 8c89ae11f..283ffa751 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -18,6 +18,7 @@ class ZipExportAttachment extends ZipExportModel $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 8b3a4b612..c630c832b 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -62,7 +62,7 @@ class ZipExportReferences 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 4d16dbc61..da43d1b36 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -38,7 +38,7 @@ class ZipReferenceParser 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 536e23806..ac07b33ae 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 @@ class ZipExportTest extends TestCase 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 b5cc2b4ca..7725004c7 100644 --- a/tests/Exports/ZipResultData.php +++ b/tests/Exports/ZipResultData.php @@ -10,4 +10,13 @@ class ZipResultData 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/89] 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 2fd9b7c55..7c2d09d4f 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 @@ class ApiEntityListFormatter /** * 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 @@ class ApiEntityListFormatter */ public function format(): array { - if ($this->includeRelatedTitles) { - $this->loadRelatedTitles(); - } + $this->loadRelatedData(); $results = []; @@ -122,7 +113,7 @@ class ApiEntityListFormatter /** * 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 5072bd3b4..28a3b53e6 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 @@ class SearchApiController extends ApiController * 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 @@ class SearchApiController extends ApiController $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 7fa1a304e..f9c17fa16 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 bb45b7959..f60a12f75 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 b80ed4530..3f2eb395c 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 @@ class SearchApiTest extends TestCase $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/89] 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 365cb1db0..ef6d33f4d 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -71,6 +71,28 @@ class LdapService 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 @@ class LdapService $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 @@ class LdapService $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 d73458231..4e2789687 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -127,7 +127,7 @@ return [ '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/89] 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 ef95bc2e8..27169a2be 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -29,7 +29,7 @@ class LdapTest extends TestCase '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 @@ class LdapTest extends TestCase 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 @@ class LdapTest extends TestCase 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/89] 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 000000000..acc803a0f --- /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 21f45aa06..99416f9fc 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 @@ return new class extends Migration */ 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 @@ return new class extends Migration '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 @@ return new class extends Migration ]; })->values()->toArray(); + // Assign to all existing roles in the system DB::table('permission_role')->insert($permissionRoles); } @@ -40,6 +41,6 @@ return new class extends Migration ->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 000000000..17bbe4cff --- /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 7e5a708ef..1a61b629a 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -43,6 +43,7 @@ return [ '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 5427cb941..c0b6b692a 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -162,6 +162,7 @@ return [ '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 0b407a860..418c0fea8 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 000000000..df8f705cb --- /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 9fa76f2bf..a77b80e4c 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 e6f3683c6..91aab13fe 100644 --- a/routes/web.php +++ b/routes/web.php @@ -206,6 +206,10 @@ Route::middleware('auth')->group(function () { // 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/89] 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 acc803a0f..9eefb0974 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -14,11 +14,17 @@ class ImportController extends Controller 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 1a61b629a..45ca4cf6b 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -44,6 +44,7 @@ return [ '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 67df41714..1c679aaa0 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 df8f705cb..b7030f114 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/89] re-write Dockerfile --- dev/docker/Dockerfile | 60 +++++++++++++++++++++---------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index 50d528faf..5040298e0 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -1,34 +1,34 @@ FROM php:8.3-apache +# Install additional dependencies +RUN apt-get update && \ + apt-get install -y \ + git \ + zip \ + unzip \ + libpng-dev \ + libldap2-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 && \ + pecl install xdebug && \ + docker-php-ext-enable xdebug + +# Install composer +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +# Configure apache +RUN a2enmod rewrite && \ + sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf && \ + sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf + +# Use the default production configuration and update it as required +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 WORKDIR /app - -RUN < Date: Tue, 29 Oct 2024 23:07:15 +0800 Subject: [PATCH 25/89] 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 5040298e0..9f283c755 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/89] 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 000000000..2ed567d63 --- /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 283ffa751..ab1f5ab75 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 @@ class ZipExportAttachment extends ZipExportModel 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 8d0c0a437..4d66f010f 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 @@ abstract class ZipExportModel implements JsonSerializable $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 d4e3c4290..ad17d5a33 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 @@ class ZipExportTag extends ZipExportModel { 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 000000000..5ad9272de --- /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 000000000..4f942e0e7 --- /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 000000000..dd41e6f8b --- /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 2a676c7c4..6971edc02 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,6 +105,8 @@ return [ '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 b7030f114..9fe596d88 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/89] 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 2ed567d63..000000000 --- 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 ab1f5ab75..e586b91b0 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -47,6 +47,6 @@ class ZipExportAttachment extends ZipExportModel '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 5a0c5806b..7e1f2d810 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book; 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 @@ class ZipExportBook extends ZipExportModel 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 cd5765f48..03df31b70 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -5,6 +5,7 @@ namespace BookStack\Exports\ZipExports\Models; 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 @@ class ZipExportChapter extends ZipExportModel 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 05d828734..3388c66df 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 @@ class ZipExportImage extends ZipExportModel 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 8075595f2..2c8b9a88a 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -5,6 +5,7 @@ namespace BookStack\Exports\ZipExports\Models; 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 @@ class ZipExportPage extends ZipExportModel 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 ad17d5a33..99abb811c 100644 --- a/app/Exports/ZipExports/Models/ZipExportTag.php +++ b/app/Exports/ZipExports/Models/ZipExportTag.php @@ -34,6 +34,6 @@ class ZipExportTag extends ZipExportModel '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 5ad9272de..e56394aca 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 dd41e6f8b..8c285deaf 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 @@ class ZipValidationHelper $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 @@ class ZipValidationHelper { 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 6971edc02..9cf5d78b6 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -105,7 +105,8 @@ return [ '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 9fe596d88..15f33e6b7 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/89] 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 5885f7991..323ecef26 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -17,7 +17,9 @@ class ImportController extends Controller { // 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 @@ class ImportController extends Controller $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 e56394aca..dd56f3e70 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -18,21 +18,21 @@ class ZipExportValidator { // 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 @@ class ZipExportValidator $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 45ca4cf6b..106147335 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -45,6 +45,9 @@ return [ '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 9c40aa9ed..3f2f30331 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -105,6 +105,11 @@ return [ '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 9cf5d78b6..bc01ac47b 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,7 +106,7 @@ return [ '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 15f33e6b7..c4d7c8818 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 000000000..c9d255b1e --- /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/89] 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 323ecef26..bbf0ff57d 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 @@ class ImportController extends Controller 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 000000000..c3ac3d529 --- /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 000000000..7187a1889 --- /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 dd56f3e70..e476998c2 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 @@ class ZipExportValidator 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 4f942e0e7..bcd3c39ac 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -19,7 +19,7 @@ class ZipFileReferenceRule implements ValidationRule */ 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 8c285deaf..55c86b03b 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -4,14 +4,13 @@ namespace BookStack\Exports\ZipExports; 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 @@ class ZipValidationHelper 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 000000000..55378d583 --- /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 000000000..ed1882269 --- /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/89] 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 000000000..aaaee792e --- /dev/null +++ b/app/Exceptions/ZipValidationException.php @@ -0,0 +1,12 @@ +middleware('can:content-import'); } @@ -27,35 +28,17 @@ class ImportController extends Controller 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 000000000..c8157967b --- /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 227649d8f..fa53c4ae4 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -4,59 +4,15 @@ namespace BookStack\Uploads; 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 @@ class AttachmentService */ public function streamAttachmentFromStorage(Attachment $attachment) { - return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getReadStream($attachment->path); } /** @@ -72,7 +28,7 @@ class AttachmentService */ public function getAttachmentFileSize(Attachment $attachment): int { - return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getSize($attachment->path); } /** @@ -195,15 +151,9 @@ class AttachmentService * 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 @@ class AttachmentService */ 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 000000000..278484e51 --- /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/89] 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 09b2ae73c..5ec9b9cf0 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 640b4c108..582fff975 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 @@ class ImportController extends Controller 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 c3ac3d529..520d8ea6c 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 @@ use Illuminate\Database\Eloquent\Model; * @property Carbon $created_at * @property Carbon $updated_at */ -class Import extends Model +class Import extends Model implements Loggable { use HasFactory; @@ -38,4 +39,24 @@ class Import extends Model 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 c8157967b..d7e169ad1 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\ZipValidationException; 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 @@ class ImportRepo ) { } + /** + * @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 @@ class ImportRepo 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 8facf5dab..090cf523a 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -152,10 +152,8 @@ abstract class Controller extends BaseController /** * 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 092398ef0..7c3454d41 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -84,6 +84,14 @@ return [ '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 106147335..e2d8e47c5 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -48,6 +48,12 @@ return [ '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 000000000..843a05246 --- /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 c4d7c8818..be9de4c0e 100644 --- a/resources/views/exports/import.blade.php +++ b/resources/views/exports/import.blade.php @@ -38,6 +38,19 @@
    + +
    +

    {{ 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 000000000..5ff6600f2 --- /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 91aab13fe..c490bb3b3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -209,6 +209,8 @@ Route::middleware('auth')->group(function () { // 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/89] 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 582fff975..787fd1b27 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -23,9 +23,8 @@ class ImportController extends Controller * 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 @@ class ImportController extends Controller */ public function show(int $id) { - // TODO - Test visibility access $import = $this->imports->findVisible($id); $this->setPageTitle(trans('entities.import_continue')); @@ -74,12 +72,23 @@ class ImportController extends Controller ]); } + 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 520d8ea6c..8400382fd 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 @@ use Illuminate\Database\Eloquent\Model; * @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 @@ class Import extends Model implements Loggable { 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 e2d8e47c5..4f5a53004 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -51,7 +51,11 @@ return [ '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 843a05246..ac1b8a45d 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 @@
    - + diff --git a/routes/web.php b/routes/web.php index c490bb3b3..85f833528 100644 --- a/routes/web.php +++ b/routes/web.php @@ -210,6 +210,7 @@ Route::middleware('auth')->group(function () { 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 c9d255b1e..b9a8598fa 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 @@ class ZipImportTest extends TestCase $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 @@ class ZipImportTest extends TestCase $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/89] 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 787fd1b27..a2389c725 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -72,14 +72,22 @@ class ImportController extends Controller ]); } - 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 4f5a53004..065eb043a 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -56,6 +56,8 @@ return [ '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 636367e3a..942265d04 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 c1280cfb2..0cdf4376c 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 03cd4be88..72d41ee56 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/89] 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 a2389c725..3a56ed034 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -65,10 +65,13 @@ class ImportController extends Controller { $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 @@ class ImportController extends Controller // 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 8400382fd..9c1771c46 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 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @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 @@ class Import extends Model implements Loggable { 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 d7e169ad1..3265e1c80 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 @@ class ImportRepo return $query->findOrFail($id); } + /** + * @throws FileUploadException + * @throws ZipValidationException + * @throws ZipExportException + */ public function storeFromUpload(UploadedFile $file): Import { $zipPath = $file->getRealPath(); @@ -50,15 +60,23 @@ class ImportRepo 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 @@ class ImportRepo 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 e586b91b0..1dbdc7333 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 @@ class ZipExportAttachment extends ZipExportModel 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 7e1f2d810..0dc4e93d4 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 @@ class ZipExportBook extends ZipExportModel 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 03df31b70..50440d61a 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 @@ class ZipExportChapter extends ZipExportModel 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 3388c66df..691eb918f 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -25,6 +25,11 @@ class ZipExportImage extends ZipExportModel return $instance; } + public function metadataOnly(): void + { + // + } + public static function validate(ZipValidationHelper $context, array $data): array { $rules = [ @@ -36,4 +41,16 @@ class ZipExportImage extends ZipExportModel 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 4d66f010f..d3a8c3567 100644 --- a/app/Exports/ZipExports/Models/ZipExportModel.php +++ b/app/Exports/ZipExports/Models/ZipExportModel.php @@ -26,4 +26,32 @@ abstract class ZipExportModel implements JsonSerializable * 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 2c8b9a88a..3a876e7aa 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 @@ class ZipExportPage extends ZipExportModel 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 99abb811c..b6c9e338a 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 @@ class ZipExportTag extends ZipExportModel 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 7187a1889..c3e47da04 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 @@ class ZipExportReader /** * @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 e476998c2..e27ae53c7 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -38,7 +38,6 @@ class ZipExportValidator 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 55378d583..74a2bcd65 100644 --- a/database/factories/Exports/ImportFactory.php +++ b/database/factories/Exports/ImportFactory.php @@ -23,9 +23,8 @@ class ImportFactory extends Factory 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 ed1882269..0784591b8 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 @@ return new class extends Migration $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 065eb043a..ae1c1e8d4 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -45,7 +45,7 @@ return [ '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 @@ return [ '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 942265d04..2cf3cbf82 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 63977947d..40867377f 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 000000000..811a3b31b --- /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 5ff6600f2..fd53095a4 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 b9a8598fa..2b40100aa 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -4,6 +4,9 @@ namespace Tests\Exports; 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 @@ class ZipImportTest extends TestCase { $admin = $this->users->admin(); $this->actingAs($admin); - $resp = $this->runImportFromFile($this->zipUploadFromData([ + $data = [ 'book' => [ 'name' => 'My great book name', 'chapters' => [ @@ -149,13 +152,13 @@ class ZipImportTest extends TestCase ] ], ], - ])); + ]; + + $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 @@ class ZipImportTest extends TestCase 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/89] 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 000000000..2403c5144 --- /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 3265e1c80..b94563545 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\ZipExportChapter; 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 @@ class ImportRepo 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 @@ class ImportRepo 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 e27ae53c7..889804f20 100644 --- a/app/Exports/ZipExports/ZipExportValidator.php +++ b/app/Exports/ZipExports/ZipExportValidator.php @@ -10,20 +10,19 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage; 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 000000000..2f784ebea --- /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 278484e51..e6ac368d0 100644 --- a/app/Uploads/FileStorage.php +++ b/app/Uploads/FileStorage.php @@ -5,6 +5,7 @@ namespace BookStack\Uploads; 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 @@ class FileStorage 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 @@ class FileStorage */ 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/89] 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 2030b050c..2be6083e3 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -18,17 +18,12 @@ use Illuminate\Http\UploadedFile; 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 1dbdc7333..c6615e1dc 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 @@ class ZipExportAttachment extends ZipExportModel $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 @@ class ZipExportAttachment extends ZipExportModel $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 @@ class ZipExportAttachment extends ZipExportModel $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 b6c9e338a..6b4720fca 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 @@ class ZipExportTag extends ZipExportModel $instance = new self(); $instance->name = $model->name; $instance->value = $model->value; - $instance->order = $model->order; return $instance; } @@ -36,7 +34,6 @@ class ZipExportTag extends ZipExportModel $rules = [ 'name' => ['required', 'string', 'min:1'], 'value' => ['nullable', 'string'], - 'order' => ['nullable', 'integer'], ]; return $context->validateData($data, $rules); @@ -48,7 +45,6 @@ class ZipExportTag extends ZipExportModel $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 c3e47da04..ebc2fbbc9 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -73,6 +73,14 @@ class ZipExportReader 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 2f784ebea..2b897ff91 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -5,18 +5,33 @@ namespace BookStack\Exports\ZipExports; 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 @@ class ZipImportRunner $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 6cee7356d..7e5df3f01 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/89] 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 beddfe8e6..e85992a9d 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -315,7 +315,11 @@ class ExportFormatter 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 @@ class ExportFormatter { $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/89] 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 1bc15392c..68b1c398f 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -87,6 +87,17 @@ class PageRepo 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 @@ class PageRepo 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 c630c832b..0de409fa1 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -85,9 +85,9 @@ class ZipExportReferences // 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 @@ class ZipExportReferences 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 @@ class ZipExportReferences 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 000000000..8062886e5 --- /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 2b897ff91..345c22be1 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -23,8 +23,6 @@ use Illuminate\Http\UploadedFile; class ZipImportRunner { protected array $tempFilesToCleanup = []; // TODO - protected array $createdImages = []; // TODO - protected array $createdAttachments = []; // TODO public function __construct( protected FileStorage $storage, @@ -32,6 +30,7 @@ class ZipImportRunner protected ChapterRepo $chapterRepo, protected BookRepo $bookRepo, protected ImageService $imageService, + protected ZipImportReferences $references, ) { } @@ -68,6 +67,11 @@ class ZipImportRunner // 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 @@ class ZipImportRunner // 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 @@ class ZipImportRunner } // TODO - Pages + $this->references->addChapter($chapter, $exportChapter); + return $chapter; } @@ -122,7 +130,9 @@ class ZipImportRunner $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 @@ class ZipImportRunner '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 da43d1b36..5929383b4 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -15,27 +15,23 @@ use BookStack\References\ModelResolvers\PagePermalinkModelResolver; 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 @@ class ZipReferenceParser 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 @@ class ZipReferenceParser 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/89] 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 2403c5144..452365c6e 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 ec5ac8080..4d2c83090 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -79,18 +79,21 @@ class ImportController extends Controller $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 b94563545..d169d4845 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 @@ class ImportRepo } /** - * @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 8062886e5..3bce16bbb 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -110,7 +110,7 @@ class ZipImportReferences { 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 @@ class ZipImportReferences 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 345c22be1..9f19f03e2 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -12,17 +12,22 @@ use BookStack\Entities\Repos\PageRepo; 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 @@ class ZipImportRunner 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 @@ class ZipImportRunner } $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 @@ class ZipImportRunner // (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 @@ class ZipImportRunner '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 @@ class ZipImportRunner '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 @@ class ZipImportRunner { $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 @@ class ZipImportRunner 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 @@ class ZipImportRunner } 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 @@ class ZipImportRunner 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/89] 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 | 71 +++++++++++-------- 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, 146 insertions(+), 69 deletions(-) diff --git a/app/Exports/Controllers/ImportController.php b/app/Exports/Controllers/ImportController.php index 4d2c83090..d8dceed2f 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -4,7 +4,7 @@ declare(strict_types=1); 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 @@ class ImportController extends Controller 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 @@ class ImportController extends Controller $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 @@ class ImportController extends Controller $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 d169d4845..f72386c47 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\Models\ZipExportPage; 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 @@ class ImportRepo $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 3bce16bbb..b23d5e72b 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -139,4 +139,21 @@ class ZipImportReferences ]); } } + + + /** + * @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 9f19f03e2..c5b9da319 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -47,14 +47,17 @@ class ZipImportRunner * 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 @@ class ZipImportRunner // 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 @@ class ZipImportRunner $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'); - - // TODO - Delete import/zip after import? - // Do this in parent repo? - return $entity; } - protected function cleanup() + /** + * 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); + } + + foreach ($this->references->attachments() as $attachment) { + if (!$attachment->external) { + $this->attachmentService->deleteFileInStorage($attachment); + } + } + + $this->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 @@ class ZipImportRunner { $errors = []; - // TODO - Extract messages to language files - // TODO - Ensure these are shown to users on failure - $chapters = []; $pages = []; $images = []; @@ -266,7 +279,7 @@ class ZipImportRunner 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 @@ class ZipImportRunner 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 @@ class ZipImportRunner 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 fa53c4ae4..033f23341 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -151,7 +151,7 @@ class AttachmentService * 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 e501cc7b1..5c455cf86 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -153,11 +153,19 @@ class ImageService */ 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 ae1c1e8d4..26a563a7e 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -52,6 +52,7 @@ return [ '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 @@ return [ '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 3f2f30331..ced80a32c 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -109,6 +109,12 @@ return [ '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 40867377f..e4f199aa2 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 fd53095a4..2f7659c46 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/89] 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 | 139 ++++++++++++++++--- tests/Exports/ZipTestHelper.php | 47 +++++++ 5 files changed, 195 insertions(+), 18 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 d8dceed2f..a20c341fb 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -70,9 +70,11 @@ class ImportController extends Controller ]); } + /** + * 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 74a2bcd65..5d0b4f892 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 000000000..7bdd8ecbb --- /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 2b40100aa..3644e9bdc 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 @@ class ZipImportTest extends TestCase 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 @@ class ZipImportTest extends TestCase 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 @@ class ZipImportTest extends TestCase ], ]; - $resp = $this->runImportFromFile($this->zipUploadFromData($data)); + $resp = $this->runImportFromFile(ZipTestHelper::zipUploadFromData($data)); $this->assertDatabaseHas('imports', [ 'name' => 'My great book name', @@ -217,7 +218,7 @@ class ZipImportTest extends TestCase 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 @@ class ZipImportTest extends TestCase $this->delete("/import/{$adminImport->id}")->assertRedirect('/import'); } + public function test_run_simple_success_scenario() + { + $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()); + } + + public function test_import_run_access_limited() + { + $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); + + $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.'); + } + 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); - } } diff --git a/tests/Exports/ZipTestHelper.php b/tests/Exports/ZipTestHelper.php new file mode 100644 index 000000000..3a9b34354 --- /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/89] 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 b23d5e72b..da0581df6 100644 --- a/app/Exports/ZipExports/ZipImportReferences.php +++ b/app/Exports/ZipExports/ZipImportReferences.php @@ -97,10 +97,12 @@ class ZipImportReferences } 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 c5b9da319..27d859e59 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -233,6 +233,10 @@ class ZipImportRunner $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 5c455cf86..038e6aa41 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -33,9 +33,10 @@ class ImageService 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 7bdd8ecbb..f07b3f41b 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 @@ class ZipImportRunnerTest extends TestCase $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 3a9b34354..2196f361c 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -8,7 +8,7 @@ use ZipArchive; 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 @@ class ZipTestHelper } $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 @@ class ZipTestHelper } } - 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/89] 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 f07b3f41b..c833fadda 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 @@ class ZipImportRunnerTest extends TestCase 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/89] 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 c6615e1dc..4f5b2f236 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -43,7 +43,7 @@ class ZipExportAttachment extends ZipExportModel 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 0dc4e93d4..47ab8f0a6 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -70,7 +70,7 @@ class ZipExportBook extends ZipExportModel 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 50440d61a..5a5fe350f 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -59,7 +59,7 @@ class ZipExportChapter extends ZipExportModel 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 691eb918f..89083b15b 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -33,7 +33,7 @@ class ZipExportImage extends ZipExportModel 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 3a876e7aa..16e7e9255 100644 --- a/app/Exports/ZipExports/Models/ZipExportPage.php +++ b/app/Exports/ZipExports/Models/ZipExportPage.php @@ -68,7 +68,7 @@ class ZipExportPage extends ZipExportModel 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 bcd3c39ac..7d6c829cf 100644 --- a/app/Exports/ZipExports/ZipFileReferenceRule.php +++ b/app/Exports/ZipExports/ZipFileReferenceRule.php @@ -4,7 +4,6 @@ namespace BookStack\Exports\ZipExports; 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 000000000..ea2b25392 --- /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 55c86b03b..7659c228b 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 @@ class ZipValidationHelper 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 bc01ac47b..fdfc3d9a9 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -107,6 +107,7 @@ return [ '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 000000000..4cacea95e --- /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/89] 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 89083b15b..e0e7d1198 100644 --- a/app/Exports/ZipExports/Models/ZipExportImage.php +++ b/app/Exports/ZipExports/Models/ZipExportImage.php @@ -32,10 +32,11 @@ class ZipExportImage extends ZipExportModel 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 ebc2fbbc9..6b88ef61c 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -7,6 +7,7 @@ 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; class ZipExportReader @@ -81,6 +82,17 @@ class ZipExportReader 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 7d6c829cf..90e78c060 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 @@ class ZipFileReferenceRule implements ValidationRule 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 27d859e59..d25a1621f 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -228,6 +228,9 @@ class ZipImportRunner 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 @@ class ZipImportRunner 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 7659c228b..fd9cd7844 100644 --- a/app/Exports/ZipExports/ZipValidationHelper.php +++ b/app/Exports/ZipExports/ZipValidationHelper.php @@ -33,9 +33,9 @@ class ZipValidationHelper 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 @@ class ZipValidationHelper 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 fdfc3d9a9..d9b982d1e 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -106,6 +106,7 @@ return [ '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 ac07b33ae..12531239f 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -107,12 +107,10 @@ class ZipExportTest extends TestCase [ 'name' => 'Exporty', 'value' => 'Content', - 'order' => 1, ], [ 'name' => 'Another', 'value' => '', - 'order' => 2, ] ], $pageData['tags']); } @@ -162,7 +160,6 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 4cacea95e..c453ef294 100644 --- a/tests/Exports/ZipExportValidatorTests.php +++ b/tests/Exports/ZipExportValidatorTest.php @@ -11,7 +11,7 @@ use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Uploads\Image; use Tests\TestCase; -class ZipExportValidatorTests extends TestCase +class ZipExportValidatorTest extends TestCase { protected array $filesToRemove = []; @@ -71,4 +71,23 @@ class ZipExportValidatorTests extends TestCase $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 c833fadda..d3af6df76 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -358,4 +358,39 @@ class ZipImportRunnerTest extends TestCase 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/89] 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 47ab8f0a6..4f641d25b 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -36,6 +36,20 @@ class ZipExportBook extends ZipExportModel } } + 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 5a5fe350f..bf2dc78f8 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 @@ class ZipExportChapter extends ZipExportModel } } + 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 16e7e9255..097443df0 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 6b88ef61c..c3d5c23cf 100644 --- a/app/Exports/ZipExports/ZipExportReader.php +++ b/app/Exports/ZipExports/ZipExportReader.php @@ -5,7 +5,6 @@ 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 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 811a3b31b..5da4b2140 100644 --- a/resources/views/exports/parts/import-item.blade.php +++ b/resources/views/exports/parts/import-item.blade.php @@ -16,11 +16,13 @@ $model - object @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/89] 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 8ad5e14cb..12c991a51 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 000000000..a793d30a2 --- /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 2cf3cbf82..2106f86e6 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 e4f199aa2..a28b79bb3 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/89] 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 a20c341fb..b938dac8e 100644 --- a/app/Exports/Controllers/ImportController.php +++ b/app/Exports/Controllers/ImportController.php @@ -89,6 +89,8 @@ class ImportController extends Controller 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 0de409fa1..bf5e02133 100644 --- a/app/Exports/ZipExports/ZipExportReferences.php +++ b/app/Exports/ZipExports/ZipExportReferences.php @@ -127,11 +127,12 @@ class ZipExportReferences 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 ced80a32c..9d7383796 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -110,6 +110,7 @@ return [ '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 12531239f..6e8462f59 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -274,6 +274,32 @@ class ZipExportTest extends TestCase $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/89] 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 12c991a51..24e60bd97 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 1d6abfe20..63900888a 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 3644e9bdc..ad0e6b241 100644 --- a/tests/Exports/ZipImportTest.php +++ b/tests/Exports/ZipImportTest.php @@ -168,6 +168,8 @@ class ZipImportTest extends TestCase $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 @@ class ZipImportTest extends TestCase $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 @@ class ZipImportTest extends TestCase $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 @@ class ZipImportTest extends TestCase $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 2196f361c..d830d8eb6 100644 --- a/tests/Exports/ZipTestHelper.php +++ b/tests/Exports/ZipTestHelper.php @@ -20,7 +20,14 @@ class ZipTestHelper $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/89] 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 5929383b4..a6560e3f2 100644 --- a/app/Exports/ZipExports/ZipReferenceParser.php +++ b/app/Exports/ZipExports/ZipReferenceParser.php @@ -11,6 +11,7 @@ use BookStack\References\ModelResolvers\CrossLinkModelResolver; 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 @@ class ZipReferenceParser */ 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 @@ class ZipReferenceParser 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 331dd593b..2c6c9fecd 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 @@ class ImageModelResolver implements CrossLinkModelResolver 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 dc4abc0f2..ddaa26a94 100644 --- a/app/Uploads/ImageStorage.php +++ b/app/Uploads/ImageStorage.php @@ -110,10 +110,20 @@ class ImageStorage } /** - * 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 @@ class ImageStorage $basePath = $storageUrl ?: url('/'); - return rtrim($basePath, '/') . $filePath; + return rtrim($basePath, '/'); } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 6e8462f59..17891c73d 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -300,6 +300,30 @@ class ZipExportTest extends TestCase $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/89] 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 7e5df3f01..fbb317858 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/89] 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 42fb03541..4c5c638f5 100644 --- a/app/Exports/ZipExports/ZipExportBuilder.php +++ b/app/Exports/ZipExports/ZipExportBuilder.php @@ -69,8 +69,8 @@ class ZipExportBuilder $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 000000000..ee1e90d03 --- /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 fbb317858..754cb4d3e 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 17891c73d..ebe07d052 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -54,8 +54,10 @@ class ZipExportTest extends TestCase $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/89] 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 9f283c755..edab90ca1 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 e91d34a71..b09edda88 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 3c7a6e9d2..0324140f8 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/89] 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 ef6d33f4d..e5037ad2f 100644 --- a/app/Access/LdapService.php +++ b/app/Access/LdapService.php @@ -72,25 +72,23 @@ class LdapService } /** - * 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 @@ class LdapService { $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 @@ class LdapService $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 4e2789687..d73458231 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -127,7 +127,7 @@ return [ '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 27169a2be..9a00c983a 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -29,7 +29,7 @@ class LdapTest extends TestCase '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 @@ class LdapTest extends TestCase 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 @@ class LdapTest extends TestCase $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/89] 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 e85992a9d..0af68b8db 100644 --- a/app/Entities/Tools/ExportFormatter.php +++ b/app/Entities/Tools/ExportFormatter.php @@ -316,8 +316,9 @@ class ExportFormatter { $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 @@ class ExportFormatter { $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 7aafa3b79..97b1ff1bc 100644 --- a/tests/Entity/ExportTest.php +++ b/tests/Entity/ExportTest.php @@ -417,23 +417,35 @@ class ExportTest extends TestCase 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/89] 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 7c2d09d4f..3c94d96ee 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 @@ class ApiEntityListFormatter } /** - * 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 @@ class ApiEntityListFormatter */ public function format(): array { - $this->loadRelatedData(); - $results = []; foreach ($this->list as $item) { @@ -110,23 +109,6 @@ class ApiEntityListFormatter 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 28a3b53e6..79cd8cfab 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -9,21 +9,18 @@ use Illuminate\Http\Request; 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 @@ class SearchApiController extends ApiController $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 f60a12f75..2ad896416 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 3f2eb395c..9da7900ca 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 @@ class SearchApiTest extends TestCase $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/89] 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 5fd671a76..c70a906a0 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 56f364501..c24dc9ebb 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 09d01bffd..7843027d7 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 f7230595a..a4d74210e 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 000000000..37ca1cdef --- /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 88c6d5678..002d825d6 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 4e69dc21c..6517d939e 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 74c8d5a7f..a1c8813c3 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 2975315cc..3e962ec72 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 fe2b97570..884fe9153 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 5026a0129..c20329e4b 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 b9ca01169..aa0d5d611 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 d937060c6..bc5c3f1d2 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 dbadaf346..542eae4db 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 3adc10d0e..000000000 --- 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 b5483c500..062394a98 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 07e35d443..e049d5e7c 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 2c7bfdbba..3f0b0c495 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 a49cccd26..817a235a7 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 0ec9220dd..3cfc96442 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 2dd99d369..97634f96b 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 67c2d91b2..02838eba0 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/89] 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 5599604c0..81eff674a 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 a4d74210e..e9d14ef11 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 37ca1cdef..bf4fc08ca 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 d8525fb36..983111434 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 947e591b4..a4e2d2313 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 000000000..0f30263ba --- /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 000000000..53caca801 --- /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 a94f9ee0b..be4b97ba3 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 66374bf5f..cf85045cd 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 bc5c3f1d2..c585c028a 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 5f2d9dcc0..466be7498 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 4d88bde0e..0523b7f71 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 fd7731f90..d76937ed6 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 5df6245f5..000000000 --- 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 39ae7bf8a..000000000 --- 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 062394a98..7b274eba1 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 05bdb5dcc..0384a3bf1 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 f86e33c31..e0d1e7077 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 5a64cdc30..f88b22c3f 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 3cfc96442..d724730e3 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/89] 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 c20329e4b..33b021298 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; +function $hasNestedListWithoutLabel(node: ListItemNode): boolean { + const children = node.getChildren(); + let hasLabel = false; + let hasNestedList = false; - 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); - } - - 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 e22fbf771..138c895e6 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 581db0294..523c7eb12 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 11887b436..000000000 --- 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 4b05fa62e..000000000 --- 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 7b274eba1..7e0ce9daf 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 3f0b0c495..5f7f41ef0 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 da8c0eae3..62a784d83 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 d724730e3..1be802ebf 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 30a97cbc1..646f341c2 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/89] Lexical: Merged custom table node code --- .../lexical/table/LexicalTableCellNode.ts | 134 ++++++---- .../wysiwyg/lexical/table/LexicalTableNode.ts | 96 ++++++- .../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, 322 insertions(+), 787 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 455d39bf6..72676b9ba 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 357ba3e73..ab1630053 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'; +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 = SerializedElementNode; +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 eddea69a2..07db2b65d 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; - if (PIXEL_VALUE_REG_EXP.test(domNode_.style.height)) { - height = parseFloat(domNode_.style.height); + const height = sizeToPixels(domNode_.style.height); + rowNode.setHeight(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 812cccc0d..6c3317c5d 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 285d587bf..5dbf03d9e 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 793302cfe..000000000 --- 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 f4702f36d..000000000 --- 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 c25c06c65..000000000 --- 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 7e0ce9daf..03213e262 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 fc4196f0a..2e4883d88 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 5a41c85b3..3cfe9592c 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 30ff3abc5..6f026ca18 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 37f1b6f01..4256fdafc 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 f631fb804..d3d892550 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 12c19b0fb..1e024e4c7 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 607deffe1..dfe21b936 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 aa8ec89ba..ed947ddcd 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/89] 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} (75%) diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index c4403773b..9066b402f 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 c24dc9ebb..806452056 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 7843027d7..fccf1ae23 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 f3c6f7105..28a203100 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 bf4fc08ca..572c9448b 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 002d825d6..9624af67e 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 6517d939e..f6f57c91c 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 983111434..d1ba53597 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 b1ea099ac..c54760ff2 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 71849bb45..eac9c8295 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 138c895e6..6edf0d64a 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 31db8fab1..24440ec80 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 cfe32ec85..6f97ba751 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 76c171971..cbe691848 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 de87696f3..178b0d953 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 bd37b200c..e69f97848 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 0f30263ba..30563c09d 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 b6d362b62..9f42ad732 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 64fe8f77b..a675665ac 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 53caca801..f0d97fe98 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 72676b9ba..1fc6b42bb 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 ab1630053..9443747a6 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 6c3317c5d..e098a21e4 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 75% rename from resources/js/wysiwyg/nodes/index.ts rename to resources/js/wysiwyg/nodes.ts index 03213e262..eb836bdce 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 e049d5e7c..2ee831d74 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 5f7f41ef0..6a1345fac 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 37d3df588..daae32e19 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 44d332939..d53bcb482 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 f0f46ddc6..98edf44b3 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 e0d1e7077..b36fd1c4f 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 fd95f9f35..f9c029ff1 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 228566d44..f00a08bb5 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 3cfe9592c..63fa24c80 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 2e4f2939c..fa8ff48be 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 7c0975da7..185cd5dcc 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 fb5543005..ffd8e603b 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 1be802ebf..a5f06f147 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 2c13427d9..85bae18e5 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 97634f96b..b5cc78955 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 02838eba0..28e729e92 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/89] 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 534663a54..cdad252c9 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 28a203100..5d7632919 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 38ecf03bc..97b634503 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 81eff674a..e08547c13 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 fb5c98f8a..6e3a3861a 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 1f7c4cfc3..7bf485ca1 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 123cb3375..7ef370f4b 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 c4dedd47d..122516d45 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 a4e2d2313..e5064121a 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 523c7eb12..567714bcd 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 497e096b1..8c7729dbf 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 466be7498..cc09d1735 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 6848e5532..2879decda 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 d5b85ccaa..1548216cf 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 d76937ed6..cae4f1aae 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 a70200d63..54cd8b54f 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 fb04e6284..8c31496de 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/89] 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 033f23341..dabd53729 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -116,16 +116,18 @@ class AttachmentService */ 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 b03f280ac..b23465879 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 @@ class AttachmentsApiTest extends TestCase } 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/89] 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 0324140f8..ea3e692a1 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 68ec85fff..1912106c2 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 42531194e..08af25d14 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 24e60bd97..12c991a51 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/89] 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 9e96e920b..8c248caee 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 e629e7726..29deda3a0 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 5f14cb9db..a516b4426 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 c77c1d8b3..bf7a7a0fc 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 18d1bc18f..888b32527 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 7d130bb0c..b0176d64e 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 b33cb4d05..e273f1942 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 1c679aaa0..b66688f8d 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 d72b66729..3ec7275a4 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 1d5defa97..edaff0810 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 6c78419d8..8175db948 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 00aa04f48..fd76f498e 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 426f7961c..17bcfcfbf 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 14f8918dc..6b5e2f837 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 a3da33621..16be32fb3 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 56e66195c..04fd31e6d 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 129e47821..8cc96df41 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 35586bf58..26539656e 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 cfa1ebdf8..8dd7be375 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 2106f86e6..c6642d0ca 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/89] 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 cc48e0f9b..6607969af 100644 --- a/app/Access/LoginService.php +++ b/app/Access/LoginService.php @@ -5,6 +5,7 @@ namespace BookStack\Access; 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 @@ class LoginService * 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 @@ class LoginService * * @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 @@ class LoginService */ 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 000000000..163484c5a --- /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/89] 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 d9fc4e7aa..c7d9d6502 100644 --- a/app/Search/SearchIndex.php +++ b/app/Search/SearchIndex.php @@ -30,7 +30,7 @@ class SearchIndex { $this->deleteEntityTerms($entity); $terms = $this->entityToTermDataArray($entity); - SearchTerm::query()->insert($terms); + $this->insertTerms($terms); } /** @@ -46,10 +46,7 @@ class SearchIndex array_push($terms, ...$entityTerms); } - $chunkedTerms = array_chunk($terms, 500); - foreach ($chunkedTerms as $termChunk) { - SearchTerm::query()->insert($termChunk); - } + $this->insertTerms($terms); } /** @@ -99,6 +96,19 @@ class SearchIndex $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 cabf23bd3..5ace70e3a 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\Tag; 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 @@ class EntitySearchTest extends TestCase $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 abe06407e..28491c3af 100644 --- a/tests/LanguageTest.php +++ b/tests/LanguageTest.php @@ -27,8 +27,10 @@ class LanguageTest extends TestCase } // 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/89] 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 dbeaf9472..647835aeb 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 b2f8b3d2b..cb9dedcb2 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 10cfbe172..2d6d280b2 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 269f32632..ee9cf39bc 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/89] 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 6ed3deedf..4c7942a9e 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 654f41a96..c23898bbc 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 0e841a042..6314aa7b5 100644 --- a/resources/views/attachments/manager-list.blade.php +++ b/resources/views/attachments/manager-list.blade.php @@ -15,23 +15,27 @@ 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 de448d93a..2eaf21d9c 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -267,6 +267,50 @@ class AttachmentTest extends TestCase } } + 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/89] 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 b5f339b2c..3a9b0c1dc 100644 --- a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php @@ -7,6 +7,7 @@ use BookStack\Activity\Notifications\Messages\BaseActivityNotification; 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 @@ abstract class BaseNotificationHandler implements NotificationHandler } // 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 605b60fd4..c405b07ae 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -13,6 +13,8 @@ use BookStack\Activity\Tools\UserEntityWatchOptions; 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 @@ class WatchTest extends TestCase } } + 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/89] 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 c1e38e72f..a617ee2da 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -30,6 +30,7 @@ class BookApiController extends ApiController { $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 a665bcb6b..b512f2d05 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -26,6 +26,7 @@ class BookshelfApiController extends ApiController { $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 0f8458fed..50c8c49e6 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 4b1a1b43f..d5debfaef 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 0de98dc32..084cb59bd 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 @@ class BooksApiTest extends TestCase '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 be276e110..ba13c0153 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -4,6 +4,7 @@ namespace Tests\Api; 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 @@ class ShelvesApiTest extends TestCase '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 1897abefa..22e554f74 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Book; 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/89] 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 2b736d81e..611653d6a 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -34,7 +34,9 @@
    - @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 37d9b3835..d92f13f0b 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -202,9 +202,13 @@ class UserManagementTest extends TestCase 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() From 97b201f61f98aaccf779d08634c247c8cfbbfbb5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 14 Dec 2024 12:35:13 +0000 Subject: [PATCH 73/89] Lexical: Added auto links on enter/space --- resources/js/wysiwyg/index.ts | 2 + .../services/__tests__/auto-links.test.ts | 91 +++++++++++++++++++ resources/js/wysiwyg/services/auto-links.ts | 74 +++++++++++++++ resources/js/wysiwyg/utils/selection.ts | 4 + 4 files changed, 171 insertions(+) create mode 100644 resources/js/wysiwyg/services/__tests__/auto-links.test.ts create mode 100644 resources/js/wysiwyg/services/auto-links.ts diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 9066b402f..510ab1f92 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -15,6 +15,7 @@ import {el} from "./utils/dom"; import {registerShortcuts} from "./services/shortcuts"; import {registerNodeResizer} from "./ui/framework/helpers/node-resizer"; import {registerKeyboardHandling} from "./services/keyboard-handling"; +import {registerAutoLinks} from "./services/auto-links"; export function createPageEditorInstance(container: HTMLElement, htmlContent: string, options: Record = {}): SimpleWysiwygEditorInterface { const config: CreateEditorArgs = { @@ -64,6 +65,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st registerTaskListHandler(editor, editArea), registerDropPasteHandling(context), registerNodeResizer(context), + registerAutoLinks(editor), ); listenToCommonEvents(editor); diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts new file mode 100644 index 000000000..d3b120b70 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -0,0 +1,91 @@ +import {initializeUnitTest} from "lexical/__tests__/utils"; +import {SerializedLinkNode} from "@lexical/link"; +import { + $getRoot, + ParagraphNode, + SerializedParagraphNode, + SerializedTextNode, + TextNode +} from "lexical"; +import {registerAutoLinks} from "../auto-links"; + +describe('Auto-link service tests', () => { + initializeUnitTest((testEnv) => { + + test('space after link in text', async () => { + const {editor} = testEnv; + + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.update(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(35, 35); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: ' ', + keyCode: 62, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + const link = paragraph.children[1] as SerializedLinkNode; + expect(link.url).toBe('https://example.com?test=true'); + const linkText = link.children[0] as SerializedTextNode; + expect(linkText.text).toBe('https://example.com?test=true'); + }); + + test('enter after link in text', async () => { + const {editor} = testEnv; + + registerAutoLinks(editor); + let pNode!: ParagraphNode; + + editor.update(() => { + pNode = new ParagraphNode(); + const text = new TextNode('Some https://example.com?test=true text'); + pNode.append(text); + $getRoot().append(pNode); + + text.select(35, 35); + }); + + editor.commitUpdates(); + + const pDomEl = editor.getElementByKey(pNode.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'Enter', + keyCode: 66, + }); + pDomEl?.dispatchEvent(event); + + editor.commitUpdates(); + + const paragraph = editor!.getEditorState().toJSON().root + .children[0] as SerializedParagraphNode; + expect(paragraph.children[1].type).toBe('link'); + + const link = paragraph.children[1] as SerializedLinkNode; + expect(link.url).toBe('https://example.com?test=true'); + const linkText = link.children[0] as SerializedTextNode; + expect(linkText.text).toBe('https://example.com?test=true'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts new file mode 100644 index 000000000..44a78ec85 --- /dev/null +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -0,0 +1,74 @@ +import { + $getSelection, BaseSelection, + COMMAND_PRIORITY_NORMAL, + KEY_ENTER_COMMAND, + KEY_SPACE_COMMAND, + LexicalEditor, + TextNode +} from "lexical"; +import {$getTextNodeFromSelection} from "../utils/selection"; +import {$createLinkNode, LinkNode} from "@lexical/link"; + + +function isLinkText(text: string): boolean { + const lower = text.toLowerCase(); + if (!lower.startsWith('http')) { + return false; + } + + const linkRegex = /(http|https):\/\/(\S+)\.\S+$/; + return linkRegex.test(text); +} + + +function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, editor: LexicalEditor) { + const selectionRange = selection.getStartEndPoints(); + if (!selectionRange) { + return; + } + + const cursorPoint = selectionRange[0].offset - 1; + const nodeText = node.getTextContent(); + const rTrimText = nodeText.slice(0, cursorPoint); + const priorSpaceIndex = rTrimText.lastIndexOf(' '); + const startIndex = priorSpaceIndex + 1; + const textSegment = nodeText.slice(startIndex, cursorPoint); + + if (!isLinkText(textSegment)) { + return; + } + + editor.update(() => { + const linkNode: LinkNode = $createLinkNode(textSegment); + linkNode.append(new TextNode(textSegment)); + + const splits = node.splitText(startIndex, cursorPoint); + const targetIndex = splits.length === 3 ? 1 : 0; + const targetText = splits[targetIndex]; + if (targetText) { + targetText.replace(linkNode); + } + }); +} + + +export function registerAutoLinks(editor: LexicalEditor): () => void { + + const handler = (payload: KeyboardEvent): boolean => { + const selection = $getSelection(); + const textNode = $getTextNodeFromSelection(selection); + if (textNode && selection) { + handlePotentialLinkEvent(textNode, selection, editor); + } + + return false; + }; + + const unregisterSpace = editor.registerCommand(KEY_SPACE_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + const unregisterEnter = editor.registerCommand(KEY_ENTER_COMMAND, handler, COMMAND_PRIORITY_NORMAL); + + return (): void => { + unregisterSpace(); + unregisterEnter(); + }; +} \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 28e729e92..167ab32ad 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -51,6 +51,10 @@ export function $getNodeFromSelection(selection: BaseSelection | null, matcher: return null; } +export function $getTextNodeFromSelection(selection: BaseSelection | null): TextNode|null { + return $getNodeFromSelection(selection, $isTextNode) as TextNode|null; +} + export function $selectionContainsTextFormat(selection: BaseSelection | null, format: TextFormatType): boolean { if (!selection) { return false; From a71aa241ada17bfc5497c59914774260003b3339 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 14 Dec 2024 15:17:33 +0000 Subject: [PATCH 74/89] Lexical: Added dark mode styles, fixed autolink range --- .../services/__tests__/auto-links.test.ts | 4 +- resources/js/wysiwyg/services/auto-links.ts | 2 +- resources/sass/_editor.scss | 47 ++++++++++++------- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts index d3b120b70..30dc92565 100644 --- a/resources/js/wysiwyg/services/__tests__/auto-links.test.ts +++ b/resources/js/wysiwyg/services/__tests__/auto-links.test.ts @@ -24,7 +24,7 @@ describe('Auto-link service tests', () => { pNode.append(text); $getRoot().append(pNode); - text.select(35, 35); + text.select(34, 34); }); editor.commitUpdates(); @@ -62,7 +62,7 @@ describe('Auto-link service tests', () => { pNode.append(text); $getRoot().append(pNode); - text.select(35, 35); + text.select(34, 34); }); editor.commitUpdates(); diff --git a/resources/js/wysiwyg/services/auto-links.ts b/resources/js/wysiwyg/services/auto-links.ts index 44a78ec85..1c3b1c730 100644 --- a/resources/js/wysiwyg/services/auto-links.ts +++ b/resources/js/wysiwyg/services/auto-links.ts @@ -27,7 +27,7 @@ function handlePotentialLinkEvent(node: TextNode, selection: BaseSelection, edit return; } - const cursorPoint = selectionRange[0].offset - 1; + const cursorPoint = selectionRange[0].offset; const nodeText = node.getTextContent(); const rTrimText = nodeText.slice(0, cursorPoint); const priorSpaceIndex = rTrimText.lastIndexOf(' '); diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e273f1942..bdf6ea44c 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -8,18 +8,20 @@ // Main UI elements .editor-container { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); position: relative; &.fullscreen { z-index: 500; } } + .editor-toolbar-main { display: flex; flex-wrap: wrap; justify-content: center; border-top: 1px solid #DDD; border-bottom: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #000); } body.editor-is-fullscreen { @@ -46,6 +48,7 @@ body.editor-is-fullscreen { font-size: 12px; padding: 4px; color: #444; + @include mixins.lightDark(color, #444, #999); border-radius: 4px; display: flex; align-items: center; @@ -54,6 +57,7 @@ body.editor-is-fullscreen { } .editor-button:hover { background-color: #EEE; + @include mixins.lightDark(background-color, #EEE, #333); cursor: pointer; color: #000; } @@ -63,7 +67,7 @@ body.editor-is-fullscreen { opacity: .6; } .editor-button-active, .editor-button-active:hover { - background-color: #ceebff; + @include mixins.lightDark(background-color, #ceebff, #444); color: #000; } .editor-button-long { @@ -75,7 +79,7 @@ body.editor-is-fullscreen { } .editor-button-text { font-weight: 400; - color: #000; + @include mixins.lightDark(color, #000, #AAA); font-size: 14px; flex: 1; padding-inline-end: 4px; @@ -126,7 +130,8 @@ body.editor-is-fullscreen { } } &:hover { - outline: 1px solid #DDD; + outline: 1px solid; + @include mixins.lightDark(outline-color, #DDD, #111); outline-offset: -3px; } } @@ -137,11 +142,14 @@ body.editor-is-fullscreen { } .editor-dropdown-menu { position: absolute; - background-color: #FFF; - box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.15); + border: 1px solid; + @include mixins.lightDark(background-color, #FFF, #292929); + @include mixins.lightDark(border-color, #FFF, #333); + @include mixins.lightDark(box-shadow, 0 0 6px 0 rgba(0, 0, 0, 0.15), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); z-index: 99; display: flex; flex-direction: row; + border-radius: 3px; } .editor-dropdown-menu-vertical { display: flex; @@ -163,8 +171,8 @@ body.editor-is-fullscreen { .editor-separator { display: block; height: 1px; - background-color: #DDD; opacity: .8; + @include mixins.lightDark(background-color, #DDD, #000); } .editor-format-menu-toggle { @@ -199,6 +207,7 @@ body.editor-is-fullscreen { display: flex; border-inline: 1px solid #DDD; padding-inline: 4px; + @include mixins.lightDark(border-color, #DDD, #000); &:first-child { border-inline-start: none; } @@ -212,11 +221,12 @@ body.editor-is-fullscreen { .editor-context-toolbar { position: fixed; - background-color: #FFF; border: 1px solid #DDD; + @include mixins.lightDark(background-color, #FFF, #222); + @include mixins.lightDark(border-color, #DDD, #333); + @include mixins.lightDark(box-shadow, 0 2px 4px 0 rgba(0, 0, 0, 0.12), 0 1px 4px 0 rgba(0, 0, 0, 0.4)); padding: .2rem; border-radius: 4px; - box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12); display: flex; flex-direction: row; &:before { @@ -226,9 +236,10 @@ body.editor-is-fullscreen { width: 8px; height: 8px; position: absolute; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-top: 1px solid #DDD; border-left: 1px solid #DDD; + @include mixins.lightDark(border-color, #DDD, #333); transform: rotate(45deg); left: 50%; margin-left: -4px; @@ -252,7 +263,7 @@ body.editor-is-fullscreen { height: 100%; } .editor-modal { - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #222); border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); @@ -314,7 +325,8 @@ body.editor-is-fullscreen { display: flex; } .editor-table-creator-cell { - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); width: 15px; height: 15px; cursor: pointer; @@ -347,7 +359,7 @@ body.editor-is-fullscreen { height: 10px; border: 2px solid var(--editor-color-primary); z-index: 3; - background-color: #FFF; + @include mixins.lightDark(background-color, #FFF, #000); user-select: none; &.nw { inset-inline-start: -5px; @@ -477,10 +489,11 @@ body.editor-is-fullscreen { display: block; width: 100%; min-width: 250px; - border: 1px solid #DDD; + border: 1px solid; + @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; - color: #444; + @include mixins.lightDark(color, #444, #BBB); } textarea.editor-form-field-input { font-family: var(--font-code); @@ -557,7 +570,7 @@ textarea.editor-form-field-input { .editor-form-tab-control { font-weight: bold; font-size: 14px; - color: #444; + @include mixins.lightDark(color, #444, #666); border-bottom: 2px solid transparent; position: relative; cursor: pointer; @@ -565,7 +578,7 @@ textarea.editor-form-field-input { text-align: start; &[aria-selected="true"] { border-color: var(--editor-color-primary); - color: var(--editor-color-primary); + color: var(--editor-color-primary) !important; } &[aria-selected="true"]:after, &:hover:after { background-color: var(--editor-color-primary); From 5f07f31c9fc21c4f82b757eb2e78027ff3ad6337 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 14:03:08 +0000 Subject: [PATCH 75/89] Lexical: Added mobile toolbar support Adds dynamic and fixed (out of DOM order) positioning with location adjustment depending on space. Also adds smarter hiding to prevent disappearing when mouse leaves but within the same space as the toggle. --- .../ui/framework/blocks/button-with-menu.ts | 1 + .../ui/framework/blocks/dropdown-button.ts | 3 ++ .../wysiwyg/ui/framework/helpers/dropdowns.ts | 52 +++++++++++++++++-- resources/js/wysiwyg/ui/toolbars.ts | 4 +- resources/sass/_editor.scss | 8 +++ resources/sass/_pages.scss | 1 + 6 files changed, 62 insertions(+), 7 deletions(-) diff --git a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts index 30dd237f6..2aec7c335 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/button-with-menu.ts @@ -16,6 +16,7 @@ export class EditorButtonWithMenu extends EditorContainerUiElement { button: {label: 'Menu', icon: caretDownIcon}, showOnHover: false, direction: 'vertical', + showAside: false, }, menuItems); this.addChildren(this.dropdownButton); } diff --git a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts index cba141f6c..d7f02d573 100644 --- a/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts +++ b/resources/js/wysiwyg/ui/framework/blocks/dropdown-button.ts @@ -7,12 +7,14 @@ import {EditorMenuButton} from "./menu-button"; export type EditorDropdownButtonOptions = { showOnHover?: boolean; direction?: 'vertical'|'horizontal'; + showAside?: boolean; button: EditorBasicButtonDefinition|EditorButton; }; const defaultOptions: EditorDropdownButtonOptions = { showOnHover: false, direction: 'horizontal', + showAside: undefined, button: {label: 'Menu'}, } @@ -65,6 +67,7 @@ export class EditorDropdownButton extends EditorContainerUiElement { handleDropdown({toggle: button, menu : menu, showOnHover: this.options.showOnHover, + showAside: typeof this.options.showAside === 'boolean' ? this.options.showAside : (this.options.direction === 'vertical'), onOpen : () => { this.open = true; this.getContext().manager.triggerStateUpdateForElement(this.button); diff --git a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts index e8cef3c8d..ccced6858 100644 --- a/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts +++ b/resources/js/wysiwyg/ui/framework/helpers/dropdowns.ts @@ -1,20 +1,48 @@ - - - interface HandleDropdownParams { toggle: HTMLElement; menu: HTMLElement; showOnHover?: boolean, onOpen?: Function | undefined; onClose?: Function | undefined; + showAside?: boolean; +} + +function positionMenu(menu: HTMLElement, toggle: HTMLElement, showAside: boolean) { + const toggleRect = toggle.getBoundingClientRect(); + const menuBounds = menu.getBoundingClientRect(); + + menu.style.position = 'fixed'; + + if (showAside) { + let targetLeft = toggleRect.right; + const isRightOOB = toggleRect.right + menuBounds.width > window.innerWidth; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.left - menuBounds.width, 0); + } + + menu.style.top = toggleRect.top + 'px'; + menu.style.left = targetLeft + 'px'; + } else { + const isRightOOB = toggleRect.left + menuBounds.width > window.innerWidth; + let targetLeft = toggleRect.left; + if (isRightOOB) { + targetLeft = Math.max(toggleRect.right - menuBounds.width, 0); + } + + menu.style.top = toggleRect.bottom + 'px'; + menu.style.left = targetLeft + 'px'; + } } export function handleDropdown(options: HandleDropdownParams) { - const {menu, toggle, onClose, onOpen, showOnHover} = options; + const {menu, toggle, onClose, onOpen, showOnHover, showAside} = options; let clickListener: Function|null = null; const hide = () => { menu.hidden = true; + menu.style.removeProperty('position'); + menu.style.removeProperty('left'); + menu.style.removeProperty('top'); if (clickListener) { window.removeEventListener('click', clickListener as EventListener); } @@ -25,6 +53,7 @@ export function handleDropdown(options: HandleDropdownParams) { const show = () => { menu.hidden = false + positionMenu(menu, toggle, Boolean(showAside)); clickListener = (event: MouseEvent) => { if (!toggle.contains(event.target as HTMLElement) && !menu.contains(event.target as HTMLElement)) { hide(); @@ -44,5 +73,18 @@ export function handleDropdown(options: HandleDropdownParams) { toggle.addEventListener('mouseenter', toggleShowing); } - menu.parentElement?.addEventListener('mouseleave', hide); + menu.parentElement?.addEventListener('mouseleave', (event: MouseEvent) => { + + // Prevent mouseleave hiding if withing the same bounds of the toggle. + // Avoids hiding in the event the mouse is interrupted by a high z-index + // item like a browser scrollbar. + const toggleBounds = toggle.getBoundingClientRect(); + const withinX = event.clientX <= toggleBounds.right && event.clientX >= toggleBounds.left; + const withinY = event.clientY <= toggleBounds.bottom && event.clientY >= toggleBounds.top; + const withinToggle = withinX && withinY; + + if (!withinToggle) { + hide(); + } + }); } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 35146e5a4..886e1394b 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -149,8 +149,8 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai new EditorOverflowContainer(4, [ new EditorButton(link), - new EditorDropdownButton({button: table, direction: 'vertical'}, [ - new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true}, [ + new EditorDropdownButton({button: table, direction: 'vertical', showAside: false}, [ + new EditorDropdownButton({button: {label: 'Insert', format: 'long'}, showOnHover: true, showAside: true}, [ new EditorTableCreator(), ]), new EditorSeparator(), diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index bdf6ea44c..e48131837 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -24,6 +24,14 @@ @include mixins.lightDark(border-color, #DDD, #000); } +@include mixins.smaller-than(vars.$bp-xl) { + .editor-toolbar-main { + overflow-x: scroll; + flex-wrap: nowrap; + justify-content: start; + } +} + body.editor-is-fullscreen { overflow: hidden; .edit-area { diff --git a/resources/sass/_pages.scss b/resources/sass/_pages.scss index 17bcfcfbf..45e58ffc8 100755 --- a/resources/sass/_pages.scss +++ b/resources/sass/_pages.scss @@ -26,6 +26,7 @@ width: 100%; border-radius: 8px; box-shadow: vars.$bs-card; + min-width: 300px; @include mixins.lightDark(background-color, #FFF, #333) } From 2f119d3033c9543a22109c34d2b77ef99e5486af Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 15:29:00 +0000 Subject: [PATCH 76/89] Lexical: Adjusted modals and content area for mobile sizes --- resources/sass/_editor.scss | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index e48131837..3eef4c2c6 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -48,6 +48,7 @@ body.editor-is-fullscreen { .editor-content-wrap { position: relative; overflow-y: scroll; + padding-inline: vars.$s; flex: 1; } @@ -275,6 +276,9 @@ body.editor-is-fullscreen { border-radius: 4px; overflow: hidden; box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.3); + margin: vars.$xs; + max-height: 100%; + overflow-y: auto; } .editor-modal-header { display: flex; @@ -490,19 +494,29 @@ body.editor-is-fullscreen { /** * Form elements */ +$inputWidth: 260px; + .editor-form-field-wrapper { margin-bottom: .5rem; } .editor-form-field-input { display: block; - width: 100%; - min-width: 250px; + width: $inputWidth; + min-width: 100px; + max-width: 100%; border: 1px solid; @include mixins.lightDark(border-color, #DDD, #000); padding: .5rem; border-radius: 4px; @include mixins.lightDark(color, #444, #BBB); } + +@include mixins.smaller-than(vars.$bp-xs) { + .editor-form-field-input { + min-width: 160px; + } +} + textarea.editor-form-field-input { font-family: var(--font-code); width: 350px; @@ -575,6 +589,17 @@ textarea.editor-form-field-input { align-items: stretch; gap: .25rem; } + +@include mixins.smaller-than(vars.$bp-m) { + .editor-form-tab-container { + flex-direction: column; + gap: .5rem; + } + .editor-form-tab-controls { + flex-direction: row; + } +} + .editor-form-tab-control { font-weight: bold; font-size: 14px; @@ -601,7 +626,8 @@ textarea.editor-form-field-input { } } .editor-form-tab-contents { - width: 360px; + width: $inputWidth; + max-width: 100%; } .editor-action-input-container { display: flex; @@ -612,6 +638,9 @@ textarea.editor-form-field-input { .editor-button { margin-bottom: 12px; } + input { + width: $inputWidth - 40px; + } } // Editor theme styles From 3f86937f74f2e5dc1e11fc2fd84694ce0793308f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 17:11:02 +0000 Subject: [PATCH 77/89] Lexical: Made summary part of details node To provide more control of the summary as part of details. To support, added a way to ignore elements during import DOM, allowing up to read summaries when parsing details without duplicate nodes involved. --- .../js/wysiwyg/lexical/core/LexicalNode.ts | 7 +- resources/js/wysiwyg/lexical/html/index.ts | 5 + .../lexical/rich-text/LexicalDetailsNode.ts | 98 ++++++++----------- resources/js/wysiwyg/nodes.ts | 4 +- 4 files changed, 54 insertions(+), 60 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/LexicalNode.ts b/resources/js/wysiwyg/lexical/core/LexicalNode.ts index c6bc2e642..a6c9b6023 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalNode.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalNode.ts @@ -142,10 +142,15 @@ export type DOMConversionMap = Record< >; type NodeName = string; +/** + * Output for a DOM conversion. + * Node can be set to 'ignore' to ignore the conversion and handling of the DOMNode + * including all its children. + */ export type DOMConversionOutput = { after?: (childLexicalNodes: Array) => Array; forChild?: DOMChildConversion; - node: null | LexicalNode | Array; + node: null | LexicalNode | Array | 'ignore'; }; export type DOMExportOutputMap = Map< diff --git a/resources/js/wysiwyg/lexical/html/index.ts b/resources/js/wysiwyg/lexical/html/index.ts index 3e962ec72..5c3cb6cce 100644 --- a/resources/js/wysiwyg/lexical/html/index.ts +++ b/resources/js/wysiwyg/lexical/html/index.ts @@ -217,6 +217,11 @@ function $createNodesFromDOM( if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; + + if (transformNodes === 'ignore') { + return lexicalNodes; + } + currentLexicalNode = Array.isArray(transformNodes) ? transformNodes[transformNodes.length - 1] : transformNodes; diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 178b0d953..18d471103 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -5,18 +5,19 @@ import { LexicalEditor, LexicalNode, SerializedElementNode, Spread, - EditorConfig, + EditorConfig, DOMExportOutput, } from 'lexical'; -import {el} from "../../utils/dom"; import {extractDirectionFromElement} from "lexical/nodes/common"; export type SerializedDetailsNode = Spread<{ id: string; + summary: string; }, SerializedElementNode> export class DetailsNode extends ElementNode { __id: string = ''; + __summary: string = ''; static getType() { return 'details'; @@ -32,10 +33,21 @@ export class DetailsNode extends ElementNode { return self.__id; } + setSummary(summary: string) { + const self = this.getWritable(); + self.__summary = summary; + } + + getSummary(): string { + const self = this.getLatest(); + return self.__summary; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; + newNode.__summary = node.__summary; return newNode; } @@ -49,6 +61,11 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + const summary = document.createElement('summary'); + summary.textContent = this.__summary; + summary.setAttribute('contenteditable', 'false'); + el.append(summary); + return el; } @@ -71,20 +88,42 @@ export class DetailsNode extends ElementNode { node.setDirection(extractDirectionFromElement(element)); } + const summaryElem = Array.from(element.children).find(e => e.nodeName === 'SUMMARY'); + node.setSummary(summaryElem?.textContent || ''); + return {node}; }, priority: 3, }; }, + summary(node: HTMLElement): DOMConversion|null { + return { + conversion: (element: HTMLElement): DOMConversionOutput|null => { + return {node: 'ignore'}; + }, + priority: 3, + }; + }, }; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const element = this.createDOM(editor._config, editor); + const editable = element.querySelectorAll('[contenteditable]'); + for (const elem of editable) { + elem.removeAttribute('contenteditable'); + } + + return {element}; + } + exportJSON(): SerializedDetailsNode { return { ...super.exportJSON(), type: 'details', version: 1, id: this.__id, + summary: this.__summary, }; } @@ -104,58 +143,3 @@ export function $createDetailsNode() { export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode { return node instanceof DetailsNode; } - -export class SummaryNode extends ElementNode { - - static getType() { - return 'summary'; - } - - static clone(node: SummaryNode) { - return new SummaryNode(node.__key); - } - - createDOM(_config: EditorConfig, _editor: LexicalEditor) { - return el('summary'); - } - - updateDOM(prevNode: DetailsNode, dom: HTMLElement) { - return false; - } - - static importDOM(): DOMConversionMap|null { - return { - summary(node: HTMLElement): DOMConversion|null { - return { - conversion: (element: HTMLElement): DOMConversionOutput|null => { - return { - node: new SummaryNode(), - }; - }, - priority: 3, - }; - }, - }; - } - - exportJSON(): SerializedElementNode { - return { - ...super.exportJSON(), - type: 'summary', - version: 1, - }; - } - - static importJSON(serializedNode: SerializedElementNode): SummaryNode { - return $createSummaryNode(); - } - -} - -export function $createSummaryNode(): SummaryNode { - return new SummaryNode(); -} - -export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode { - return node instanceof SummaryNode; -} diff --git a/resources/js/wysiwyg/nodes.ts b/resources/js/wysiwyg/nodes.ts index eb836bdce..8a47f322d 100644 --- a/resources/js/wysiwyg/nodes.ts +++ b/resources/js/wysiwyg/nodes.ts @@ -8,7 +8,7 @@ import { } from "lexical"; import {LinkNode} from "@lexical/link"; import {ImageNode} from "@lexical/rich-text/LexicalImageNode"; -import {DetailsNode, SummaryNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {ListItemNode, ListNode} from "@lexical/list"; import {TableCellNode, TableNode, TableRowNode} from "@lexical/table"; import {HorizontalRuleNode} from "@lexical/rich-text/LexicalHorizontalRuleNode"; @@ -34,7 +34,7 @@ export function getNodesForPageEditor(): (KlassConstructor | TableCellNode, ImageNode, // TODO - Alignment HorizontalRuleNode, - DetailsNode, SummaryNode, + DetailsNode, CodeBlockNode, DiagramNode, MediaNode, // TODO - Alignment From 5887322178c0fd95319ed104abee3bd4733c5d0d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 15 Dec 2024 18:13:49 +0000 Subject: [PATCH 78/89] Lexical: Added details toolbar Includes unwrap and toggle open actions. --- resources/icons/editor/details-toggle.svg | 1 + .../lexical/rich-text/LexicalDetailsNode.ts | 33 ++++++++++- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 59 ++++++++++++++++++- .../js/wysiwyg/ui/defaults/forms/objects.ts | 34 +++++++++++ resources/js/wysiwyg/ui/defaults/modals.ts | 6 +- resources/js/wysiwyg/ui/index.ts | 7 ++- resources/js/wysiwyg/ui/toolbars.ts | 10 +++- 7 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 resources/icons/editor/details-toggle.svg diff --git a/resources/icons/editor/details-toggle.svg b/resources/icons/editor/details-toggle.svg new file mode 100644 index 000000000..37194e059 --- /dev/null +++ b/resources/icons/editor/details-toggle.svg @@ -0,0 +1 @@ + diff --git a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts index 18d471103..3c845359a 100644 --- a/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts +++ b/resources/js/wysiwyg/lexical/rich-text/LexicalDetailsNode.ts @@ -18,6 +18,7 @@ export type SerializedDetailsNode = Spread<{ export class DetailsNode extends ElementNode { __id: string = ''; __summary: string = ''; + __open: boolean = false; static getType() { return 'details'; @@ -43,11 +44,22 @@ export class DetailsNode extends ElementNode { return self.__summary; } + setOpen(open: boolean) { + const self = this.getWritable(); + self.__open = open; + } + + getOpen(): boolean { + const self = this.getLatest(); + return self.__open; + } + static clone(node: DetailsNode): DetailsNode { const newNode = new DetailsNode(node.__key); newNode.__id = node.__id; newNode.__dir = node.__dir; newNode.__summary = node.__summary; + newNode.__open = node.__open; return newNode; } @@ -61,17 +73,34 @@ export class DetailsNode extends ElementNode { el.setAttribute('dir', this.__dir); } + if (this.__open) { + el.setAttribute('open', 'true'); + } + const summary = document.createElement('summary'); summary.textContent = this.__summary; summary.setAttribute('contenteditable', 'false'); + summary.addEventListener('click', event => { + event.preventDefault(); + _editor.update(() => { + this.select(); + }) + }); + el.append(summary); return el; } updateDOM(prevNode: DetailsNode, dom: HTMLElement) { + + if (prevNode.__open !== this.__open) { + dom.toggleAttribute('open', this.__open); + } + return prevNode.__id !== this.__id - || prevNode.__dir !== this.__dir; + || prevNode.__dir !== this.__dir + || prevNode.__summary !== this.__summary; } static importDOM(): DOMConversionMap|null { @@ -114,6 +143,8 @@ export class DetailsNode extends ElementNode { elem.removeAttribute('contenteditable'); } + element.removeAttribute('open'); + return {element}; } diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index f9c029ff1..6612c0dc4 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -19,6 +19,9 @@ import editIcon from "@icons/edit.svg"; import diagramIcon from "@icons/editor/diagram.svg"; import {$createDiagramNode, DiagramNode} from "@lexical/rich-text/LexicalDiagramNode"; import detailsIcon from "@icons/editor/details.svg"; +import detailsToggleIcon from "@icons/editor/details-toggle.svg"; +import tableDeleteIcon from "@icons/editor/table-delete.svg"; +import tagIcon from "@icons/tag.svg"; import mediaIcon from "@icons/editor/media.svg"; import {$createDetailsNode, $isDetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {$isMediaNode, MediaNode} from "@lexical/rich-text/LexicalMediaNode"; @@ -29,7 +32,7 @@ import { } from "../../../utils/selection"; import {$isDiagramNode, $openDrawingEditorForNode, showDiagramManagerForInsert} from "../../../utils/diagrams"; import {$createLinkedImageNodeFromImageData, showImageManager} from "../../../utils/images"; -import {$showImageForm, $showLinkForm} from "../forms/objects"; +import {$showDetailsForm, $showImageForm, $showLinkForm} from "../forms/objects"; import {formatCodeBlock} from "../../../utils/formats"; export const link: EditorButtonDefinition = { @@ -216,4 +219,58 @@ export const details: EditorButtonDefinition = { isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDetailsNode); } +} + +export const detailsEditLabel: EditorButtonDefinition = { + label: 'Edit label', + icon: tagIcon, + action(context: EditorUiContext) { + context.editor.getEditorState().read(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + $showDetailsForm(details, context); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsToggle: EditorButtonDefinition = { + label: 'Toggle open/closed', + icon: detailsToggleIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + details.setOpen(!details.getOpen()); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } +} + +export const detailsUnwrap: EditorButtonDefinition = { + label: 'Unwrap', + icon: tableDeleteIcon, + action(context: EditorUiContext) { + context.editor.update(() => { + const details = $getNodeFromSelection($getSelection(), $isDetailsNode); + if ($isDetailsNode(details)) { + const children = details.getChildren(); + for (const child of children) { + details.insertBefore(child); + } + details.remove(); + context.manager.triggerLayoutUpdate(); + } + }) + }, + isActive(selection: BaseSelection | null): boolean { + return false; + } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/objects.ts b/resources/js/wysiwyg/ui/defaults/forms/objects.ts index f00a08bb5..21d333c3a 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/objects.ts @@ -19,6 +19,7 @@ import searchIcon from "@icons/search.svg"; import {showLinkSelector} from "../../../utils/links"; import {LinkField} from "../../framework/blocks/link-field"; import {insertOrUpdateLink} from "../../../utils/formats"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; export function $showImageForm(image: ImageNode, context: EditorUiContext) { const imageModal: EditorFormModal = context.manager.createModal('image'); @@ -262,4 +263,37 @@ export const media: EditorFormDefinition = { } }, ], +}; + +export function $showDetailsForm(details: DetailsNode|null, context: EditorUiContext) { + const linkModal = context.manager.createModal('details'); + if (!details) { + return; + } + + linkModal.show({ + summary: details.getSummary() + }); +} + +export const details: EditorFormDefinition = { + submitText: 'Save', + async action(formData, context: EditorUiContext) { + context.editor.update(() => { + const node = $getNodeFromSelection($getSelection(), $isDetailsNode); + const summary = (formData.get('summary') || '').toString().trim(); + if ($isDetailsNode(node)) { + node.setSummary(summary); + } + }); + + return true; + }, + fields: [ + { + label: 'Toggle label', + name: 'summary', + type: 'text', + }, + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index c43923778..da3859266 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,5 +1,5 @@ import {EditorFormModalDefinition} from "../framework/modals"; -import {image, link, media} from "./forms/objects"; +import {details, image, link, media} from "./forms/objects"; import {source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; @@ -32,4 +32,8 @@ export const modals: Record = { title: 'Table Properties', form: tableProperties, }, + details: { + title: 'Edit collapsible block', + form: details, + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 3811f44b9..40df43347 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -1,6 +1,6 @@ import {LexicalEditor} from "lexical"; import { - getCodeToolbarContent, + getCodeToolbarContent, getDetailsToolbarContent, getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent @@ -56,7 +56,6 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro selector: '.editor-code-block-wrap', content: getCodeToolbarContent(), }); - manager.registerContextToolbar('table', { selector: 'td,th', content: getTableToolbarContent(), @@ -64,6 +63,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro return originalTarget.closest('table') as HTMLTableElement; } }); + manager.registerContextToolbar('details', { + selector: 'details', + content: getDetailsToolbarContent(), + }); // Register image decorator listener manager.registerDecoratorType('code', CodeBlockDecorator); diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 886e1394b..1230cbdd2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -68,7 +68,7 @@ import { } from "./defaults/buttons/lists"; import { codeBlock, - details, + details, detailsEditLabel, detailsToggle, detailsUnwrap, diagram, diagramManager, editCodeBlock, horizontalRule, @@ -253,4 +253,12 @@ export function getTableToolbarContent(): EditorUiElement[] { new EditorButton(deleteColumn), ]), ]; +} + +export function getDetailsToolbarContent(): EditorUiElement[] { + return [ + new EditorButton(detailsEditLabel), + new EditorButton(detailsToggle), + new EditorButton(detailsUnwrap), + ]; } \ No newline at end of file From 8486775edf9d7979f9d6f1f555460bf8678d6653 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Dec 2024 14:30:06 +0000 Subject: [PATCH 79/89] Lexical: Added mulitple methods to escape details block Enter on empty last line, or down on last empty line, will focus on the next node after details, or created a new paragraph to focus on if needed. --- .../js/wysiwyg/services/keyboard-handling.ts | 109 +++++++++++++++++- 1 file changed, 106 insertions(+), 3 deletions(-) diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 6a1345fac..08eed7645 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -3,7 +3,7 @@ import { $createParagraphNode, $getSelection, $isDecoratorNode, - COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_TAB_COMMAND, @@ -13,9 +13,10 @@ import { 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 {$getNearestNodeBlockParent, $getParentOfType} from "../utils/nodes"; import {$setInsetForSelection} from "../utils/lists"; import {$isListItemNode} from "@lexical/list"; +import {$isDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; function isSingleSelectedNode(nodes: LexicalNode[]): boolean { if (nodes.length === 1) { @@ -28,6 +29,10 @@ function isSingleSelectedNode(nodes: LexicalNode[]): boolean { return false; } +/** + * Delete the current node in the selection if the selection contains a single + * selected node (like image, media etc...). + */ function deleteSingleSelectedNode(editor: LexicalEditor) { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -37,6 +42,10 @@ function deleteSingleSelectedNode(editor: LexicalEditor) { } } +/** + * Insert a new empty node after the selection if the selection contains a single + * selected node (like image, media etc...). + */ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const selectionNodes = getLastSelection(editor)?.getNodes() || []; if (isSingleSelectedNode(selectionNodes)) { @@ -58,6 +67,94 @@ function insertAfterSingleSelectedNode(editor: LexicalEditor, event: KeyboardEve return false; } +/** + * Insert a new node after a details node, if inside a details node that's + * the last element, and if the cursor is at the last block within the details node. + */ +function insertAfterDetails(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null || scenario.detailsSibling) { + return false; + } + + editor.update(() => { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + }); + event?.preventDefault(); + + return true; +} + +/** + * If within a details block, move after it, creating a new node if required, if we're on + * the last empty block element within the details node. + */ +function moveAfterDetailsOnEmptyLine(editor: LexicalEditor, event: KeyboardEvent|null): boolean { + const scenario = getDetailsScenario(editor); + if (scenario === null) { + return false; + } + + if (scenario.parentBlock.getTextContent() !== '') { + return false; + } + + event?.preventDefault() + + const nextSibling = scenario.parentDetails.getNextSibling(); + editor.update(() => { + if (nextSibling) { + nextSibling.selectStart(); + } else { + const newParagraph = $createParagraphNode(); + scenario.parentDetails.insertAfter(newParagraph); + newParagraph.select(); + } + scenario.parentBlock.remove(); + }); + + return true; +} + +/** + * Get the common nodes used for a details node scenario, relative to current selection. + * Returns null if not found, or if the parent block is not the last in the parent details node. + */ +function getDetailsScenario(editor: LexicalEditor): { + parentDetails: DetailsNode; + parentBlock: LexicalNode; + detailsSibling: LexicalNode | null +} | null { + const selection = getLastSelection(editor); + const firstNode = selection?.getNodes()[0]; + if (!firstNode) { + return null; + } + + const block = $getNearestNodeBlockParent(firstNode); + const details = $getParentOfType(firstNode, $isDetailsNode); + if (!$isDetailsNode(details) || block === null) { + return null; + } + + if (block.getKey() !== details.getLastChild()?.getKey()) { + return null; + } + + const nextSibling = details.getNextSibling(); + return { + parentDetails: details, + parentBlock: block, + detailsSibling: nextSibling, + } +} + +/** + * Inset the nodes within selection when a range of nodes is selected + * or if a list node is selected. + */ function handleInsetOnTab(editor: LexicalEditor, event: KeyboardEvent|null): boolean { const change = event?.shiftKey ? -40 : 40; const selection = $getSelection(); @@ -85,17 +182,23 @@ export function registerKeyboardHandling(context: EditorUiContext): () => void { }, COMMAND_PRIORITY_LOW); const unregisterEnter = context.editor.registerCommand(KEY_ENTER_COMMAND, (event): boolean => { - return insertAfterSingleSelectedNode(context.editor, event); + return insertAfterSingleSelectedNode(context.editor, event) + || moveAfterDetailsOnEmptyLine(context.editor, event); }, COMMAND_PRIORITY_LOW); const unregisterTab = context.editor.registerCommand(KEY_TAB_COMMAND, (event): boolean => { return handleInsetOnTab(context.editor, event); }, COMMAND_PRIORITY_LOW); + const unregisterDown = context.editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event): boolean => { + return insertAfterDetails(context.editor, event); + }, COMMAND_PRIORITY_LOW); + return () => { unregisterBackspace(); unregisterDelete(); unregisterEnter(); unregisterTab(); + unregisterDown(); }; } \ No newline at end of file From e50cd33277026a93983b696014c6468a4d34fe6f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 16 Dec 2024 16:24:47 +0000 Subject: [PATCH 80/89] Lexical: Added testing for some added shortcuts Also: - Added svg loading support (dummy stub) for jest. - Updated headless test case due to node changes. - Split out editor change detected to where appropriate. - Added functions to help with testing, like mocking our context. --- dev/build/svg-blank-transform.js | 14 +++ jest.config.ts | 1 + resources/js/wysiwyg/index.ts | 36 +------ .../js/wysiwyg/lexical/core/LexicalEditor.ts | 8 ++ .../lexical/core/__tests__/utils/index.ts | 46 +++++++++ .../unit/LexicalHeadlessEditor.test.ts | 1 - .../__tests__/unit/LexicalDetailsNode.test.ts | 40 ++++++++ .../__tests__/keyboard-handling.test.ts | 95 +++++++++++++++++++ .../js/wysiwyg/services/common-events.ts | 14 ++- resources/js/wysiwyg/ui/framework/manager.ts | 18 +++- 10 files changed, 239 insertions(+), 34 deletions(-) create mode 100644 dev/build/svg-blank-transform.js create mode 100644 resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts create mode 100644 resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts diff --git a/dev/build/svg-blank-transform.js b/dev/build/svg-blank-transform.js new file mode 100644 index 000000000..5183014c8 --- /dev/null +++ b/dev/build/svg-blank-transform.js @@ -0,0 +1,14 @@ +// This is a basic transformer stub to help jest handle SVG files. +// Essentially blanks them since we don't really need to involve them +// in our tests (yet). +module.exports = { + process() { + return { + code: 'module.exports = \'\';', + }; + }, + getCacheKey() { + // The output is always the same. + return 'svgTransform'; + }, +}; diff --git a/jest.config.ts b/jest.config.ts index 3c04f05b2..53bfceb05 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -185,6 +185,7 @@ const config: Config = { // A map from regular expressions to paths to transformers transform: { "^.+.tsx?$": ["ts-jest",{}], + "^.+.svg$": ["/dev/build/svg-blank-transform.js",{}], }, // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 510ab1f92..ffdc7d7e8 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -75,38 +75,12 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st const debugView = document.getElementById('lexical-debug'); if (debugView) { debugView.hidden = true; - } - - let changeFromLoading = true; - editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { - // Watch for selection changes to update the UI on change - // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit - // for all selection changes, so this proved more reliable. - const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false); - if (selectionChange) { - editor.update(() => { - const selection = $getSelection(); - context.manager.triggerStateUpdate({ - editor, selection, - }); - }); - } - - // Emit change event to component system (for draft detection) on actual user content change - if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { - if (changeFromLoading) { - changeFromLoading = false; - } else { - window.$events.emit('editor-html-change', ''); - } - } - - // Debug logic - // console.log('editorState', editorState.toJSON()); - if (debugView) { + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Debug logic + // console.log('editorState', editorState.toJSON()); debugView.textContent = JSON.stringify(editorState.toJSON(), null, 2); - } - }); + }); + } // @ts-ignore window.debugEditorState = () => { diff --git a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts index 092429156..364f6c6b7 100644 --- a/resources/js/wysiwyg/lexical/core/LexicalEditor.ts +++ b/resources/js/wysiwyg/lexical/core/LexicalEditor.ts @@ -1188,6 +1188,14 @@ export class LexicalEditor { updateEditor(this, updateFn, options); } + /** + * Helper to run the update and commitUpdates methods in a single call. + */ + updateAndCommit(updateFn: () => void, options?: EditorUpdateOptions): void { + this.update(updateFn, options); + this.commitUpdates(); + } + /** * Focuses the editor * @param callbackFn - A function to run after the editor is focused. diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index e9d14ef11..2fc57315b 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -13,6 +13,7 @@ import {ListItemNode, ListNode} from '@lexical/list'; import {TableCellNode, TableNode, TableRowNode} from '@lexical/table'; import { + $getSelection, $isRangeSelection, createEditor, DecoratorNode, @@ -37,6 +38,10 @@ import { import {resetRandomKey} from '../../LexicalUtils'; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; +import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {EditorUiContext} from "../../../../ui/framework/core"; +import {EditorUIManager} from "../../../../ui/framework/manager"; +import {registerRichText} from "@lexical/rich-text"; type TestEnv = { @@ -420,6 +425,7 @@ const DEFAULT_NODES: NonNullable | LexicalNodeR TableRowNode, AutoLinkNode, LinkNode, + DetailsNode, TestElementNode, TestSegmentedNode, TestExcludeFromCopyElementNode, @@ -451,6 +457,7 @@ export function createTestEditor( ...config, nodes: DEFAULT_NODES.concat(customNodes), }); + return editor; } @@ -465,6 +472,26 @@ export function createTestHeadlessEditor( }); } +export function createTestContext(env: TestEnv): EditorUiContext { + const context = { + containerDOM: document.createElement('div'), + editor: env.editor, + editorDOM: document.createElement('div'), + error(text: string | Error): void { + }, + manager: new EditorUIManager(), + options: {}, + scrollDOM: document.createElement('div'), + translate(text: string): string { + return ""; + } + }; + + context.manager.setContext(context); + + return context; +} + export function $assertRangeSelection(selection: unknown): RangeSelection { if (!$isRangeSelection(selection)) { throw new Error(`Expected RangeSelection, got ${selection}`); @@ -717,4 +744,23 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); +} + +export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEditor, key: string) { + const nodeDomEl = editor.getElementByKey(node.getKey()); + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + }); + nodeDomEl?.dispatchEvent(event); +} + +export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { + editor.getEditorState().read((): void => { + const node = $getSelection()?.getNodes()[0] || null; + if (node) { + dispatchKeydownEventForNode(node, editor, key); + } + }); } \ No newline at end of file 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 122516d45..c03f1bdb2 100644 --- a/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts +++ b/resources/js/wysiwyg/lexical/headless/__tests__/unit/LexicalHeadlessEditor.test.ts @@ -62,7 +62,6 @@ describe('LexicalHeadlessEditor', () => { it('should be headless environment', async () => { expect(typeof window === 'undefined').toBe(true); expect(typeof document === 'undefined').toBe(true); - expect(typeof navigator === 'undefined').toBe(true); }); it('can update editor', async () => { diff --git a/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts new file mode 100644 index 000000000..faa31d887 --- /dev/null +++ b/resources/js/wysiwyg/lexical/rich-text/__tests__/unit/LexicalDetailsNode.test.ts @@ -0,0 +1,40 @@ +import {dispatchKeydownEventForNode, initializeUnitTest} from "lexical/__tests__/utils"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {$createParagraphNode, $getRoot, LexicalNode, ParagraphNode} from "lexical"; + +const editorConfig = Object.freeze({ + namespace: '', + theme: { + }, +}); + +describe('LexicalDetailsNode tests', () => { + initializeUnitTest((testEnv) => { + + test('createDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = details.createDOM(editorConfig, editor).outerHTML; + }); + + expect(html).toBe(`
    `); + }); + + test('exportDOM()', () => { + const {editor} = testEnv; + let html!: string; + + editor.updateAndCommit(() => { + const details = $createDetailsNode(); + html = (details.exportDOM(editor).element as HTMLElement).outerHTML; + }); + + expect(html).toBe(`
    `); + }); + + + }); +}) \ No newline at end of file diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts new file mode 100644 index 000000000..14a1ea973 --- /dev/null +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -0,0 +1,95 @@ +import { + createTestContext, + dispatchKeydownEventForNode, + dispatchKeydownEventForSelectedNode, + initializeUnitTest +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $createTextNode, + $getRoot, LexicalNode, + ParagraphNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {registerKeyboardHandling} from "../keyboard-handling"; +import {registerRichText} from "@lexical/rich-text"; + +describe('Keyboard-handling service tests', () => { + initializeUnitTest((testEnv) => { + + test('Details: down key on last lines creates new sibling node', () => { + const {editor} = testEnv; + + registerRichText(editor); + registerKeyboardHandling(createTestContext(testEnv)); + + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; + + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + detailsPara = $createParagraphNode(); + details.append(detailsPara); + $getRoot().append(details); + detailsPara.select(); + + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); + editor.commitUpdates(); + + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); + + test('Details: enter on last empy block creates new sibling node', () => { + const {editor} = testEnv; + + registerRichText(editor); + registerKeyboardHandling(createTestContext(testEnv)); + + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; + + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + const text = $createTextNode('Hello!'); + detailsPara = $createParagraphNode(); + detailsPara.append(text); + details.append(detailsPara); + $getRoot().append(details); + text.selectEnd(); + + lastRootChild = root.getLastChild(); + }); + + expect(lastRootChild).toBeInstanceOf(DetailsNode); + + dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); + editor.commitUpdates(); + + dispatchKeydownEventForSelectedNode(editor, 'Enter'); + editor.commitUpdates(); + + let detailsChildren!: LexicalNode[]; + let lastDetailsText!: string; + + editor.getEditorState().read(() => { + detailsChildren = (lastRootChild as DetailsNode).getChildren(); + lastRootChild = $getRoot().getLastChild(); + lastDetailsText = detailsChildren[0].getTextContent(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + expect(detailsChildren).toHaveLength(1); + expect(lastDetailsText).toBe('Hello!'); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/common-events.ts b/resources/js/wysiwyg/services/common-events.ts index 16522d66b..2ffa722e4 100644 --- a/resources/js/wysiwyg/services/common-events.ts +++ b/resources/js/wysiwyg/services/common-events.ts @@ -1,4 +1,4 @@ -import {LexicalEditor} from "lexical"; +import {$getSelection, LexicalEditor} from "lexical"; import { appendHtmlToEditor, focusEditor, @@ -40,4 +40,16 @@ export function listen(editor: LexicalEditor): void { window.$events.listen('editor::focus', () => { focusEditor(editor); }); + + let changeFromLoading = true; + editor.registerUpdateListener(({dirtyElements, dirtyLeaves, editorState, prevEditorState}) => { + // Emit change event to component system (for draft detection) on actual user content change + if (dirtyElements.size > 0 || dirtyLeaves.size > 0) { + if (changeFromLoading) { + changeFromLoading = false; + } else { + window.$events.emit('editor-html-change', ''); + } + } + }); } diff --git a/resources/js/wysiwyg/ui/framework/manager.ts b/resources/js/wysiwyg/ui/framework/manager.ts index 185cd5dcc..0f501d9fa 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 {BaseSelection, LexicalEditor} from "lexical"; +import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; import {DecoratorListener} from "lexical/LexicalEditor"; import type {NodeKey} from "lexical/LexicalNode"; import {EditorContextToolbar, EditorContextToolbarDefinition} from "./toolbars"; @@ -231,6 +231,22 @@ export class EditorUIManager { }); } editor.registerDecoratorListener(domDecorateListener); + + // Watch for changes to update local state + editor.registerUpdateListener(({editorState, prevEditorState}) => { + // Watch for selection changes to update the UI on change + // Used to be done via SELECTION_CHANGE_COMMAND but this would not always emit + // for all selection changes, so this proved more reliable. + const selectionChange = !(prevEditorState._selection?.is(editorState._selection) || false); + if (selectionChange) { + editor.update(() => { + const selection = $getSelection(); + this.triggerStateUpdate({ + editor, selection, + }); + }); + } + }); } protected setupEventListeners(context: EditorUiContext) { From ace8af077dfa5173c24cdf8b50eb82ccbd1dbf7e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 14:44:10 +0000 Subject: [PATCH 81/89] Lexical: Improved list tab handling, Improved test utils - Made tab work on empty list items - Improved select preservation on single list item tab - Altered test context creation for more standard testing --- .../lexical/core/__tests__/utils/index.ts | 32 +++- .../__tests__/keyboard-handling.test.ts | 164 +++++++++++------- .../js/wysiwyg/services/keyboard-handling.ts | 11 +- resources/js/wysiwyg/utils/lists.ts | 17 +- 4 files changed, 154 insertions(+), 70 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 2fc57315b..7815d4f0d 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -472,16 +472,34 @@ export function createTestHeadlessEditor( }); } -export function createTestContext(env: TestEnv): EditorUiContext { +export function createTestContext(): EditorUiContext { + + const container = document.createElement('div'); + document.body.appendChild(container); + + const scrollWrap = document.createElement('div'); + const editorDOM = document.createElement('div'); + editorDOM.setAttribute('contenteditable', 'true'); + + scrollWrap.append(editorDOM); + container.append(scrollWrap); + + const editor = createTestEditor({ + namespace: 'testing', + theme: {}, + }); + + editor.setRootElement(editorDOM); + const context = { - containerDOM: document.createElement('div'), - editor: env.editor, - editorDOM: document.createElement('div'), + containerDOM: container, + editor: editor, + editorDOM: editorDOM, error(text: string | Error): void { }, manager: new EditorUIManager(), options: {}, - scrollDOM: document.createElement('div'), + scrollDOM: scrollWrap, translate(text: string): string { return ""; } @@ -492,6 +510,10 @@ export function createTestContext(env: TestEnv): EditorUiContext { return context; } +export function destroyFromContext(context: EditorUiContext) { + context.containerDOM.remove(); +} + export function $assertRangeSelection(selection: unknown): RangeSelection { if (!$isRangeSelection(selection)) { throw new Error(`Expected RangeSelection, got ${selection}`); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 14a1ea973..0ab6935fb 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -1,95 +1,135 @@ import { - createTestContext, + createTestContext, destroyFromContext, dispatchKeydownEventForNode, dispatchKeydownEventForSelectedNode, - initializeUnitTest } from "lexical/__tests__/utils"; import { $createParagraphNode, $createTextNode, - $getRoot, LexicalNode, - ParagraphNode, + $getRoot, $getSelection, LexicalEditor, LexicalNode, + ParagraphNode, TextNode, } from "lexical"; import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {registerKeyboardHandling} from "../keyboard-handling"; import {registerRichText} from "@lexical/rich-text"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$createListItemNode, $createListNode, ListItemNode, ListNode} from "@lexical/list"; describe('Keyboard-handling service tests', () => { - initializeUnitTest((testEnv) => { - test('Details: down key on last lines creates new sibling node', () => { - const {editor} = testEnv; + let context!: EditorUiContext; + let editor!: LexicalEditor; - registerRichText(editor); - registerKeyboardHandling(createTestContext(testEnv)); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + registerRichText(editor); + registerKeyboardHandling(context); + }); - let lastRootChild!: LexicalNode|null; - let detailsPara!: ParagraphNode; + afterEach(() => { + destroyFromContext(context); + }); - editor.updateAndCommit(() => { - const root = $getRoot() - const details = $createDetailsNode(); - detailsPara = $createParagraphNode(); - details.append(detailsPara); - $getRoot().append(details); - detailsPara.select(); + test('Details: down key on last lines creates new sibling node', () => { + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; - lastRootChild = root.getLastChild(); - }); + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + detailsPara = $createParagraphNode(); + details.append(detailsPara); + $getRoot().append(details); + detailsPara.select(); - expect(lastRootChild).toBeInstanceOf(DetailsNode); - - dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); - editor.commitUpdates(); - - editor.getEditorState().read(() => { - lastRootChild = $getRoot().getLastChild(); - }); - - expect(lastRootChild).toBeInstanceOf(ParagraphNode); + lastRootChild = root.getLastChild(); }); - test('Details: enter on last empy block creates new sibling node', () => { - const {editor} = testEnv; + expect(lastRootChild).toBeInstanceOf(DetailsNode); - registerRichText(editor); - registerKeyboardHandling(createTestContext(testEnv)); + dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); + editor.commitUpdates(); - let lastRootChild!: LexicalNode|null; - let detailsPara!: ParagraphNode; + editor.getEditorState().read(() => { + lastRootChild = $getRoot().getLastChild(); + }); - editor.updateAndCommit(() => { - const root = $getRoot() - const details = $createDetailsNode(); - const text = $createTextNode('Hello!'); - detailsPara = $createParagraphNode(); - detailsPara.append(text); - details.append(detailsPara); - $getRoot().append(details); - text.selectEnd(); + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + }); - lastRootChild = root.getLastChild(); - }); + test('Details: enter on last empty block creates new sibling node', () => { + registerRichText(editor); - expect(lastRootChild).toBeInstanceOf(DetailsNode); + let lastRootChild!: LexicalNode|null; + let detailsPara!: ParagraphNode; - dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); - editor.commitUpdates(); + editor.updateAndCommit(() => { + const root = $getRoot() + const details = $createDetailsNode(); + const text = $createTextNode('Hello!'); + detailsPara = $createParagraphNode(); + detailsPara.append(text); + details.append(detailsPara); + $getRoot().append(details); + text.selectEnd(); - dispatchKeydownEventForSelectedNode(editor, 'Enter'); - editor.commitUpdates(); + lastRootChild = root.getLastChild(); + }); - let detailsChildren!: LexicalNode[]; - let lastDetailsText!: string; + expect(lastRootChild).toBeInstanceOf(DetailsNode); - editor.getEditorState().read(() => { - detailsChildren = (lastRootChild as DetailsNode).getChildren(); - lastRootChild = $getRoot().getLastChild(); - lastDetailsText = detailsChildren[0].getTextContent(); - }); + dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); + editor.commitUpdates(); - expect(lastRootChild).toBeInstanceOf(ParagraphNode); - expect(detailsChildren).toHaveLength(1); - expect(lastDetailsText).toBe('Hello!'); + dispatchKeydownEventForSelectedNode(editor, 'Enter'); + editor.commitUpdates(); + + let detailsChildren!: LexicalNode[]; + let lastDetailsText!: string; + + editor.getEditorState().read(() => { + detailsChildren = (lastRootChild as DetailsNode).getChildren(); + lastRootChild = $getRoot().getLastChild(); + lastDetailsText = detailsChildren[0].getTextContent(); + }); + + expect(lastRootChild).toBeInstanceOf(ParagraphNode); + expect(detailsChildren).toHaveLength(1); + expect(lastDetailsText).toBe('Hello!'); + }); + + test('Lists: tab on empty list item insets item', () => { + + let list!: ListNode; + let listItemB!: ListItemNode; + + editor.updateAndCommit(() => { + const root = $getRoot(); + list = $createListNode('bullet'); + const listItemA = $createListItemNode(); + listItemA.append($createTextNode('Hello!')); + listItemB = $createListItemNode(); + list.append(listItemA, listItemB); + root.append(list); + listItemB.selectStart(); + }); + + dispatchKeydownEventForNode(listItemB, editor, 'Tab'); + editor.commitUpdates(); + + editor.getEditorState().read(() => { + const list = $getRoot().getChildren()[0] as ListNode; + const listChild = list.getChildren()[0] as ListItemNode; + const children = listChild.getChildren(); + expect(children).toHaveLength(2); + expect(children[0]).toBeInstanceOf(TextNode); + expect(children[0].getTextContent()).toBe('Hello!'); + expect(children[1]).toBeInstanceOf(ListNode); + + const innerList = children[1] as ListNode; + const selectedNode = $getSelection()?.getNodes()[0]; + expect(selectedNode).toBeInstanceOf(ListItemNode); + expect(selectedNode?.getKey()).toBe(innerList.getChildren()[0].getKey()); }); }); }); \ No newline at end of file diff --git a/resources/js/wysiwyg/services/keyboard-handling.ts b/resources/js/wysiwyg/services/keyboard-handling.ts index 08eed7645..ff6117b2b 100644 --- a/resources/js/wysiwyg/services/keyboard-handling.ts +++ b/resources/js/wysiwyg/services/keyboard-handling.ts @@ -151,6 +151,15 @@ function getDetailsScenario(editor: LexicalEditor): { } } +function $isSingleListItem(nodes: LexicalNode[]): boolean { + if (nodes.length !== 1) { + return false; + } + + const node = nodes[0]; + return $isListItemNode(node) || $isListItemNode(node.getParent()); +} + /** * Inset the nodes within selection when a range of nodes is selected * or if a list node is selected. @@ -159,7 +168,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 && $isListItemNode(nodes[0].getParent()))) { + if (nodes.length > 1 || $isSingleListItem(nodes)) { editor.update(() => { $setInsetForSelection(editor, change); }); diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 646f341c2..2fc1c5f6b 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -1,4 +1,4 @@ -import {$getSelection, BaseSelection, LexicalEditor} from "lexical"; +import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical"; import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection"; import {nodeHasInset} from "./nodes"; import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list"; @@ -93,6 +93,7 @@ function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[ export function $setInsetForSelection(editor: LexicalEditor, change: number): void { const selection = $getSelection(); + const selectionBounds = selection?.getStartEndPoints(); const listItemsInSelection = getListItemsForSelection(selection); const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null); @@ -110,7 +111,19 @@ export function $setInsetForSelection(editor: LexicalEditor, change: number): vo alteredListItems.reverse(); } - $selectNodes(alteredListItems); + if (alteredListItems.length === 1 && selectionBounds) { + // Retain selection range if moving just one item + const listItem = alteredListItems[0] as ListItemNode; + let child = listItem.getChildren()[0] as TextNode; + if (!child) { + child = $createTextNode(''); + listItem.append(child); + } + child.select(selectionBounds[0].offset, selectionBounds[1].offset); + } else { + $selectNodes(alteredListItems); + } + return; } From fca8f928a380350a1a6441530a722f4578f8ae14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 16:50:03 +0000 Subject: [PATCH 82/89] Lexical: Aligned new empty item behaviour for nested lists - Makes enter on empty nested list item un-nest instead of just creating new list items. - Also updated existing lists tests to use newer helper setup. --- .../lexical/core/__tests__/utils/index.ts | 1 + .../lexical/list/LexicalListItemNode.ts | 11 +- .../unit/LexicalListItemNode.test.ts | 1235 +++++++++-------- .../__tests__/keyboard-handling.test.ts | 5 - 4 files changed, 635 insertions(+), 617 deletions(-) diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index 7815d4f0d..d90853b7c 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -776,6 +776,7 @@ export function dispatchKeydownEventForNode(node: LexicalNode, editor: LexicalEd key, }); nodeDomEl?.dispatchEvent(event); + editor.commitUpdates(); } export function dispatchKeydownEventForSelectedNode(editor: LexicalEditor, key: string) { diff --git a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts index 33b021298..239c49a8c 100644 --- a/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts +++ b/resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts @@ -271,11 +271,18 @@ export class ListItemNode extends ElementNode { insertNewAfter( _: RangeSelection, restoreSelection = true, - ): ListItemNode | ParagraphNode { + ): ListItemNode | ParagraphNode | null { if (this.getTextContent().trim() === '' && this.isLastChild()) { const list = this.getParentOrThrow(); - if (!$isListItemNode(list.getParent())) { + const parentListItem = list.getParent(); + if ($isListItemNode(parentListItem)) { + // Un-nest list item if empty nested item + parentListItem.insertAfter(this); + this.selectStart(); + return null; + } else { + // Insert empty paragraph after list if adding after last empty child const paragraph = $createParagraphNode(); list.insertAfter(paragraph, restoreSelection); this.remove(); 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 567714bcd..10ff0fc66 100644 --- a/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts +++ b/resources/js/wysiwyg/lexical/list/__tests__/unit/LexicalListItemNode.test.ts @@ -9,13 +9,13 @@ import { $createParagraphNode, $createRangeSelection, - $getRoot, + $getRoot, LexicalEditor, TextNode, } from 'lexical'; import { + createTestContext, destroyFromContext, expectHtmlToBeEqual, html, - initializeUnitTest, } from 'lexical/__tests__/utils'; import { @@ -24,49 +24,49 @@ import { ListItemNode, ListNode, } from '../..'; - -const editorConfig = Object.freeze({ - namespace: '', - theme: { - list: { - listitem: 'my-listItem-item-class', - nested: { - listitem: 'my-nested-list-listItem-class', - }, - }, - }, -}); +import {EditorUiContext} from "../../../../ui/framework/core"; +import {$htmlToBlockNodes} from "../../../../utils/nodes"; describe('LexicalListItemNode tests', () => { - initializeUnitTest((testEnv) => { - test('ListItemNode.constructor', async () => { - const {editor} = testEnv; - await editor.update(() => { - const listItemNode = new ListItemNode(); + let context!: EditorUiContext; + let editor!: LexicalEditor; - expect(listItemNode.getType()).toBe('listitem'); + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); - expect(listItemNode.getTextContent()).toBe(''); - }); + afterEach(() => { + destroyFromContext(context); + }); - expect(() => new ListItemNode()).toThrow(); + test('ListItemNode.constructor', async () => { + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect(listItemNode.getType()).toBe('listitem'); + + expect(listItemNode.getTextContent()).toBe(''); }); - test('ListItemNode.createDOM()', async () => { - const {editor} = testEnv; + expect(() => new ListItemNode()).toThrow(); + }); - await editor.update(() => { - const listItemNode = new ListItemNode(); + test('ListItemNode.createDOM()', async () => { - expectHtmlToBeEqual( - listItemNode.createDOM(editorConfig).outerHTML, + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expectHtmlToBeEqual( + listItemNode.createDOM(editor._config).outerHTML, html`
  • `, - ); + ); - expectHtmlToBeEqual( + expectHtmlToBeEqual( listItemNode.createDOM({ namespace: '', theme: {}, @@ -74,108 +74,105 @@ describe('LexicalListItemNode tests', () => { html`
  • `, + ); + }); + }); + + describe('ListItemNode.updateDOM()', () => { + test('base', async () => { + + await editor.update(() => { + const listItemNode = new ListItemNode(); + + const domElement = listItemNode.createDOM(editor._config); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, + ); + const newListItemNode = new ListItemNode(); + + const result = newListItemNode.updateDOM( + listItemNode, + domElement, + editor._config, + ); + + expect(result).toBe(false); + + expectHtmlToBeEqual( + domElement.outerHTML, + html` +
  • + `, ); }); }); - describe('ListItemNode.updateDOM()', () => { - test('base', async () => { - const {editor} = testEnv; + test('nested list', async () => { - await editor.update(() => { - const listItemNode = new ListItemNode(); + await editor.update(() => { + const parentListNode = new ListNode('bullet', 1); + const parentlistItemNode = new ListItemNode(); - const domElement = listItemNode.createDOM(editorConfig); + parentListNode.append(parentlistItemNode); + const domElement = parentlistItemNode.createDOM(editor._config); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - const newListItemNode = new ListItemNode(); - - const result = newListItemNode.updateDOM( - listItemNode, - domElement, - editorConfig, - ); - - expect(result).toBe(false); - - expectHtmlToBeEqual( - domElement.outerHTML, - html` -
  • - `, - ); - }); - }); - - test('nested list', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const parentListNode = new ListNode('bullet', 1); - const parentlistItemNode = new ListItemNode(); - - parentListNode.append(parentlistItemNode); - const domElement = parentlistItemNode.createDOM(editorConfig); - - expectHtmlToBeEqual( - domElement.outerHTML, - html` -
  • - `, - ); - const nestedListNode = new ListNode('bullet', 1); - nestedListNode.append(new ListItemNode()); - parentlistItemNode.append(nestedListNode); - const result = parentlistItemNode.updateDOM( + ); + const nestedListNode = new ListNode('bullet', 1); + nestedListNode.append(new ListItemNode()); + parentlistItemNode.append(nestedListNode); + const result = parentlistItemNode.updateDOM( parentlistItemNode, domElement, - editorConfig, - ); + editor._config, + ); - expect(result).toBe(false); + expect(result).toBe(false); - expectHtmlToBeEqual( + expectHtmlToBeEqual( domElement.outerHTML, html`
  • `, - ); - }); + ); }); }); + }); - describe('ListItemNode.replace()', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.replace()', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode1.append(new TextNode('one')); - listItemNode2 = new ListItemNode(); + listItemNode1.append(new TextNode('one')); + listItemNode2 = new ListItemNode(); - listItemNode2.append(new TextNode('two')); - listItemNode3 = new ListItemNode(); + listItemNode2.append(new TextNode('two')); + listItemNode3 = new ListItemNode(); - listItemNode3.append(new TextNode('three')); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - }); + listItemNode3.append(new TextNode('three')); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('another list item node', async () => { + + await editor.update(() => { + const newListItemNode = new ListItemNode(); + + newListItemNode.append(new TextNode('bar')); + listItemNode1.replace(newListItemNode); }); - test('another list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const newListItemNode = new ListItemNode(); - - newListItemNode.append(new TextNode('bar')); - listItemNode1.replace(newListItemNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('first list item with a non list item node', async () => { + + await editor.update(() => { + return; }); - test('first list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - return; - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); - - test('last list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode3.replace(paragraphNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -
      -
    • - one -
    • -
    • - two -
    • -
    -


    -
    - `, - ); - }); - - test('middle list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode2.replace(paragraphNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -
      -
    • - one -
    • -
    -


    -
      -
    • - three -
    • -
    -
    - `, - ); - }); - - test('the only list item with a non list item node', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -
      -
    • - one -
    • -
    -
    - `, - ); - - await editor.update(() => { - const paragraphNode = $createParagraphNode(); - listItemNode1.replace(paragraphNode); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, - html` -
    -


    -
    - `, - ); - }); + ); }); - describe('ListItemNode.remove()', () => { - // - A - // - x - // - B - test('siblings are not nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; + test('last list item with a non list item node', async () => { - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode3.replace(paragraphNode); + }); - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +
      +
    • + one +
    • +
    • + two +
    • +
    +


    +
    + `, + ); + }); - x = new ListItemNode(); - x.append(new TextNode('x')); + test('middle list item with a non list item node', async () => { - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode2.replace(paragraphNode); + }); - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +


    +
      +
    • + three +
    • +
    +
    + `, + ); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + test('the only list item with a non list item node', async () => { + + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); + }); + + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +
      +
    • + one +
    • +
    +
    + `, + ); + + await editor.update(() => { + const paragraphNode = $createParagraphNode(); + listItemNode1.replace(paragraphNode); + }); + + expectHtmlToBeEqual( + context.editorDOM.outerHTML, + html` +
    +


    +
    + `, + ); + }); + }); + + describe('ListItemNode.remove()', () => { + // - A + // - x + // - B + test('siblings are not nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); + }); + + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + // - A + // - x + // - B + test('the previous sibling is nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + B_listItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B - test('the previous sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - B_listItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -504,12 +494,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -524,39 +514,38 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A + // - x + // - B + test('the next sibling is nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + A_listItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B - test('the next sibling is nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - A_listItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem); - B_nestedListItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -574,12 +563,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -594,43 +583,42 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A + // - x + // - B + test('both siblings are nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem); + B_nestedListItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B - test('both siblings are nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem); - B_nestedListItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -652,12 +640,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -672,51 +660,50 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A1 + // - A2 + // - x + // - B + test('the previous sibling is nested deeper than the next sibling', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedlistItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedlistItem); + B_nestedlistItem.append(new TextNode('B')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A1 - // - A2 - // - x - // - B - test('the previous sibling is nested deeper than the next sibling', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem1 = new ListItemNode(); - const A_nestedListItem2 = new ListItemNode(); - const A_deeplyNestedList = new ListNode('bullet', 1); - const A_deeplyNestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem1); - A_nestedList.append(A_nestedListItem2); - A_nestedListItem1.append(new TextNode('A1')); - A_nestedListItem2.append(A_deeplyNestedList); - A_deeplyNestedList.append(A_deeplyNestedListItem); - A_deeplyNestedListItem.append(new TextNode('A2')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedlistItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedlistItem); - B_nestedlistItem.append(new TextNode('B')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -745,12 +732,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -772,51 +759,50 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A + // - x + // - B1 + // - B2 + test('the next sibling is nested deeper than the previous sibling', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem); + A_nestedListItem.append(new TextNode('A')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A - // - x - // - B1 - // - B2 - test('the next sibling is nested deeper than the previous sibling', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem); - A_nestedListItem.append(new TextNode('A')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem1 = new ListItemNode(); - const B_nestedListItem2 = new ListItemNode(); - const B_deeplyNestedList = new ListNode('bullet', 1); - const B_deeplyNestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem1); - B_nestedList.append(B_nestedListItem2); - B_nestedListItem1.append(B_deeplyNestedList); - B_nestedListItem2.append(new TextNode('B2')); - B_deeplyNestedList.append(B_deeplyNestedListItem); - B_deeplyNestedListItem.append(new TextNode('B1')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -845,12 +831,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -872,59 +858,58 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); + }); + + // - A1 + // - A2 + // - x + // - B1 + // - B2 + test('both siblings are deeply nested', async () => { + let x: ListItemNode; + + await editor.update(() => { + const root = $getRoot(); + const parent = new ListNode('bullet', 1); + + const A_listItem = new ListItemNode(); + const A_nestedList = new ListNode('bullet', 1); + const A_nestedListItem1 = new ListItemNode(); + const A_nestedListItem2 = new ListItemNode(); + const A_deeplyNestedList = new ListNode('bullet', 1); + const A_deeplyNestedListItem = new ListItemNode(); + A_listItem.append(A_nestedList); + A_nestedList.append(A_nestedListItem1); + A_nestedList.append(A_nestedListItem2); + A_nestedListItem1.append(new TextNode('A1')); + A_nestedListItem2.append(A_deeplyNestedList); + A_deeplyNestedList.append(A_deeplyNestedListItem); + A_deeplyNestedListItem.append(new TextNode('A2')); + + x = new ListItemNode(); + x.append(new TextNode('x')); + + const B_listItem = new ListItemNode(); + const B_nestedList = new ListNode('bullet', 1); + const B_nestedListItem1 = new ListItemNode(); + const B_nestedListItem2 = new ListItemNode(); + const B_deeplyNestedList = new ListNode('bullet', 1); + const B_deeplyNestedListItem = new ListItemNode(); + B_listItem.append(B_nestedList); + B_nestedList.append(B_nestedListItem1); + B_nestedList.append(B_nestedListItem2); + B_nestedListItem1.append(B_deeplyNestedList); + B_nestedListItem2.append(new TextNode('B2')); + B_deeplyNestedList.append(B_deeplyNestedListItem); + B_deeplyNestedListItem.append(new TextNode('B1')); + + parent.append(A_listItem, x, B_listItem); + root.append(parent); }); - // - A1 - // - A2 - // - x - // - B1 - // - B2 - test('both siblings are deeply nested', async () => { - const {editor} = testEnv; - let x: ListItemNode; - - await editor.update(() => { - const root = $getRoot(); - const parent = new ListNode('bullet', 1); - - const A_listItem = new ListItemNode(); - const A_nestedList = new ListNode('bullet', 1); - const A_nestedListItem1 = new ListItemNode(); - const A_nestedListItem2 = new ListItemNode(); - const A_deeplyNestedList = new ListNode('bullet', 1); - const A_deeplyNestedListItem = new ListItemNode(); - A_listItem.append(A_nestedList); - A_nestedList.append(A_nestedListItem1); - A_nestedList.append(A_nestedListItem2); - A_nestedListItem1.append(new TextNode('A1')); - A_nestedListItem2.append(A_deeplyNestedList); - A_deeplyNestedList.append(A_deeplyNestedListItem); - A_deeplyNestedListItem.append(new TextNode('A2')); - - x = new ListItemNode(); - x.append(new TextNode('x')); - - const B_listItem = new ListItemNode(); - const B_nestedList = new ListNode('bullet', 1); - const B_nestedListItem1 = new ListItemNode(); - const B_nestedListItem2 = new ListItemNode(); - const B_deeplyNestedList = new ListNode('bullet', 1); - const B_deeplyNestedListItem = new ListItemNode(); - B_listItem.append(B_nestedList); - B_nestedList.append(B_nestedListItem1); - B_nestedList.append(B_nestedListItem2); - B_nestedListItem1.append(B_deeplyNestedList); - B_nestedListItem2.append(new TextNode('B2')); - B_deeplyNestedList.append(B_deeplyNestedListItem); - B_deeplyNestedListItem.append(new TextNode('B1')); - - parent.append(A_listItem, x, B_listItem); - root.append(parent); - }); - - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -960,12 +945,12 @@ describe('LexicalListItemNode tests', () => {
    `, - ); + ); - await editor.update(() => x.remove()); + await editor.update(() => x.remove()); - expectHtmlToBeEqual( - testEnv.innerHTML, + expectHtmlToBeEqual( + context.editorDOM.innerHTML, html`
    • @@ -990,37 +975,36 @@ describe('LexicalListItemNode tests', () => {
    `, - ); - }); + ); }); + }); - describe('ListItemNode.insertNewAfter(): non-empty list items', () => { - let listNode: ListNode; - let listItemNode1: ListItemNode; - let listItemNode2: ListItemNode; - let listItemNode3: ListItemNode; + describe('ListItemNode.insertNewAfter(): non-empty list items', () => { + let listNode: ListNode; + let listItemNode1: ListItemNode; + let listItemNode2: ListItemNode; + let listItemNode3: ListItemNode; - beforeEach(async () => { - const {editor} = testEnv; + beforeEach(async () => { - await editor.update(() => { - const root = $getRoot(); - listNode = new ListNode('bullet', 1); - listItemNode1 = new ListItemNode(); + await editor.update(() => { + const root = $getRoot(); + listNode = new ListNode('bullet', 1); + listItemNode1 = new ListItemNode(); - listItemNode2 = new ListItemNode(); + listItemNode2 = new ListItemNode(); - listItemNode3 = new ListItemNode(); + listItemNode3 = new ListItemNode(); - root.append(listNode); - listNode.append(listItemNode1, listItemNode2, listItemNode3); - listItemNode1.append(new TextNode('one')); - listItemNode2.append(new TextNode('two')); - listItemNode3.append(new TextNode('three')); - }); + root.append(listNode); + listNode.append(listItemNode1, listItemNode2, listItemNode3); + listItemNode1.append(new TextNode('one')); + listItemNode2.append(new TextNode('two')); + listItemNode3.append(new TextNode('three')); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('first list item', async () => { + + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); }); - test('first list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('last list item', async () => { + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); }); - test('last list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('middle list item', async () => { + + await editor.update(() => { + listItemNode3.insertNewAfter($createRangeSelection()); }); - test('middle list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode3.insertNewAfter($createRangeSelection()); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); + }); + + test('the only list item', async () => { + await editor.update(() => { + listItemNode2.remove(); + listItemNode3.remove(); }); - test('the only list item', async () => { - const {editor} = testEnv; - - await editor.update(() => { - listItemNode2.remove(); - listItemNode3.remove(); - }); - - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); + ); - await editor.update(() => { - listItemNode1.insertNewAfter($createRangeSelection()); - }); + await editor.update(() => { + listItemNode1.insertNewAfter($createRangeSelection()); + }); - expectHtmlToBeEqual( - testEnv.outerHTML, + expectHtmlToBeEqual( + context.editorDOM.outerHTML, html`
    {
    `, - ); - }); + ); }); + }); - test('$createListItemNode()', async () => { - const {editor} = testEnv; + describe('ListItemNode.insertNewAfter()', () => { + test('new items after empty nested items un-nests the current item instead of creating new', () => { + let nestedItem!: ListItemNode; + const input = `
      +
    • + Item A +
      • Nested item A
      +
    • +
    • Item B
    • +
    `; - await editor.update(() => { - const listItemNode = new ListItemNode(); - - const createdListItemNode = $createListItemNode(); - - expect(listItemNode.__type).toEqual(createdListItemNode.__type); - expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); - expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + editor.updateAndCommit(() => { + const root = $getRoot(); + root.append(...$htmlToBlockNodes(editor, input)); + const list = root.getFirstChild() as ListNode; + const itemA = list.getFirstChild() as ListItemNode; + const nestedList = itemA.getLastChild() as ListNode; + nestedItem = nestedList.getFirstChild() as ListItemNode; + nestedList.selectEnd(); }); + + editor.updateAndCommit(() => { + nestedItem.insertNewAfter($createRangeSelection()); + const newItem = nestedItem.getNextSibling() as ListItemNode; + newItem.insertNewAfter($createRangeSelection()); + }); + + expectHtmlToBeEqual( + context.editorDOM.innerHTML, + html`
      +
    • + Item A +
      • Nested item A
      +
    • +

    • +
    • Item B
    • +
    `, + ); }); + }); - test('$isListItemNode()', async () => { - const {editor} = testEnv; + test('$createListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); - await editor.update(() => { - const listItemNode = new ListItemNode(); + const createdListItemNode = $createListItemNode(); - expect($isListItemNode(listItemNode)).toBe(true); - }); + expect(listItemNode.__type).toEqual(createdListItemNode.__type); + expect(listItemNode.__parent).toEqual(createdListItemNode.__parent); + expect(listItemNode.__key).not.toEqual(createdListItemNode.__key); + }); + }); + + test('$isListItemNode()', async () => { + await editor.update(() => { + const listItemNode = new ListItemNode(); + + expect($isListItemNode(listItemNode)).toBe(true); }); }); }); diff --git a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts index 0ab6935fb..736c3573c 100644 --- a/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts +++ b/resources/js/wysiwyg/services/__tests__/keyboard-handling.test.ts @@ -48,7 +48,6 @@ describe('Keyboard-handling service tests', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode); dispatchKeydownEventForNode(detailsPara, editor, 'ArrowDown'); - editor.commitUpdates(); editor.getEditorState().read(() => { lastRootChild = $getRoot().getLastChild(); @@ -79,10 +78,7 @@ describe('Keyboard-handling service tests', () => { expect(lastRootChild).toBeInstanceOf(DetailsNode); dispatchKeydownEventForNode(detailsPara, editor, 'Enter'); - editor.commitUpdates(); - dispatchKeydownEventForSelectedNode(editor, 'Enter'); - editor.commitUpdates(); let detailsChildren!: LexicalNode[]; let lastDetailsText!: string; @@ -115,7 +111,6 @@ describe('Keyboard-handling service tests', () => { }); dispatchKeydownEventForNode(listItemB, editor, 'Tab'); - editor.commitUpdates(); editor.getEditorState().read(() => { const list = $getRoot().getChildren()[0] as ListNode; From f4005a139b0ac102674cad0185fa6c32265e68f5 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 18:07:46 +0000 Subject: [PATCH 83/89] Lexical: Adjusted handling of child/sibling list items on nesting Sibling/child items will now remain at the same visual level during nesting/un-nested, so only the selected item level is visually altered. Also added new model-based editor content matching system for tests. --- .../lexical/core/__tests__/utils/index.ts | 43 +++++- .../js/wysiwyg/utils/__tests__/lists.test.ts | 124 ++++++++++++++++++ resources/js/wysiwyg/utils/lists.ts | 22 ++++ 3 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 resources/js/wysiwyg/utils/__tests__/lists.test.ts diff --git a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts index d90853b7c..b13bba697 100644 --- a/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts +++ b/resources/js/wysiwyg/lexical/core/__tests__/utils/index.ts @@ -30,18 +30,14 @@ import { TextNode, } from 'lexical'; -import { - CreateEditorArgs, - HTMLConfig, - LexicalNodeReplacement, -} from '../../LexicalEditor'; +import {CreateEditorArgs, HTMLConfig, LexicalNodeReplacement,} from '../../LexicalEditor'; import {resetRandomKey} from '../../LexicalUtils'; import {HeadingNode} from "@lexical/rich-text/LexicalHeadingNode"; import {QuoteNode} from "@lexical/rich-text/LexicalQuoteNode"; import {DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; import {EditorUiContext} from "../../../../ui/framework/core"; import {EditorUIManager} from "../../../../ui/framework/manager"; -import {registerRichText} from "@lexical/rich-text"; +import {turtle} from "@codemirror/legacy-modes/mode/turtle"; type TestEnv = { @@ -764,6 +760,41 @@ export function expectHtmlToBeEqual(expected: string, actual: string): void { expect(formatHtml(expected)).toBe(formatHtml(actual)); } +type nodeTextShape = { + text: string; +}; + +type nodeShape = { + type: string; + children?: (nodeShape|nodeTextShape)[]; +} + +export function getNodeShape(node: SerializedLexicalNode): nodeShape|nodeTextShape { + // @ts-ignore + const children: SerializedLexicalNode[] = (node.children || []); + + const shape: nodeShape = { + type: node.type, + }; + + if (shape.type === 'text') { + // @ts-ignore + return {text: node.text} + } + + if (children.length > 0) { + shape.children = children.map(c => getNodeShape(c)); + } + + return shape; +} + +export function expectNodeShapeToMatch(editor: LexicalEditor, expected: nodeShape[]) { + const json = editor.getEditorState().toJSON(); + const shape = getNodeShape(json.root) as nodeShape; + expect(shape.children).toMatchObject(expected); +} + function formatHtml(s: string): string { return s.replace(/>\s+<').replace(/\s*\n\s*/g, ' ').trim(); } diff --git a/resources/js/wysiwyg/utils/__tests__/lists.test.ts b/resources/js/wysiwyg/utils/__tests__/lists.test.ts new file mode 100644 index 000000000..20dcad240 --- /dev/null +++ b/resources/js/wysiwyg/utils/__tests__/lists.test.ts @@ -0,0 +1,124 @@ +import { + createTestContext, destroyFromContext, + dispatchKeydownEventForNode, expectNodeShapeToMatch, +} from "lexical/__tests__/utils"; +import { + $createParagraphNode, $getRoot, LexicalEditor, LexicalNode, + ParagraphNode, +} from "lexical"; +import {$createDetailsNode, DetailsNode} from "@lexical/rich-text/LexicalDetailsNode"; +import {EditorUiContext} from "../../ui/framework/core"; +import {$htmlToBlockNodes} from "../nodes"; +import {ListItemNode, ListNode} from "@lexical/list"; +import {$nestListItem, $unnestListItem} from "../lists"; + +describe('List Utils', () => { + + let context!: EditorUiContext; + let editor!: LexicalEditor; + + beforeEach(() => { + context = createTestContext(); + editor = context.editor; + }); + + afterEach(() => { + destroyFromContext(context); + }); + + describe('$nestListItem', () => { + test('nesting handles child items to leave at the same level', () => { + const input = `
      +
    • Inner A
    • +
    • Inner B
        +
      • Inner C
      • +
    • +
    `; + let list!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + list = $getRoot().getFirstChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $nestListItem(list.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Inner A'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner B'}]}, + {type: 'listitem', children: [{text: 'Inner C'}]}, + ] + } + ] + }, + ] + } + ]); + }); + }); + + describe('$unnestListItem', () => { + test('middle in nested list converts to new parent item at same place', () => { + const input = `
      +
    • Nested list:
        +
      • Inner A
      • +
      • Inner B
      • +
      • Inner C
      • +
    • +
    `; + let innerList!: ListNode; + + editor.updateAndCommit(() => { + $getRoot().append(...$htmlToBlockNodes(editor, input)); + innerList = (($getRoot().getFirstChild() as ListNode).getFirstChild() as ListItemNode).getLastChild() as ListNode; + }); + + editor.updateAndCommit(() => { + $unnestListItem(innerList.getChildren()[1] as ListItemNode); + }); + + expectNodeShapeToMatch(editor, [ + { + type: 'list', + children: [ + { + type: 'listitem', + children: [ + {text: 'Nested list:'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner A'}]}, + ], + } + ], + }, + { + type: 'listitem', + children: [ + {text: 'Inner B'}, + { + type: 'list', + children: [ + {type: 'listitem', children: [{text: 'Inner C'}]}, + ], + } + ], + } + ] + } + ]); + }); + }); +}); \ No newline at end of file diff --git a/resources/js/wysiwyg/utils/lists.ts b/resources/js/wysiwyg/utils/lists.ts index 2fc1c5f6b..005b05f98 100644 --- a/resources/js/wysiwyg/utils/lists.ts +++ b/resources/js/wysiwyg/utils/lists.ts @@ -10,6 +10,9 @@ export function $nestListItem(node: ListItemNode): ListItemNode { return node; } + const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null; + const nodeChildItems = nodeChildList?.getChildren() || []; + const listItems = list.getChildren() as ListItemNode[]; const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey()); const isFirst = nodeIndex === 0; @@ -27,6 +30,13 @@ export function $nestListItem(node: ListItemNode): ListItemNode { node.remove(); } + if (nodeChildList) { + for (const child of nodeChildItems) { + newListItem.insertAfter(child); + } + nodeChildList.remove(); + } + return newListItem; } @@ -38,6 +48,8 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { return node; } + const laterSiblings = node.getNextSiblings(); + parentListItem.insertAfter(node); if (list.getChildren().length === 0) { list.remove(); @@ -47,6 +59,16 @@ export function $unnestListItem(node: ListItemNode): ListItemNode { parentListItem.remove(); } + if (laterSiblings.length > 0) { + const childList = $createListNode(list.getListType()); + childList.append(...laterSiblings); + node.append(childList); + } + + if (list.getChildrenSize() === 0) { + list.remove(); + } + return node; } From ebe2ca7faff580ccb8064920a24fb2fad6412105 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 17 Dec 2024 22:40:28 +0000 Subject: [PATCH 84/89] Lexical: Added about button/view Re-used existing route and moved tinymce help to its own different route. Added test to cover. Added new external-content block to support in editor UI. --- lang/en/editor.php | 2 + .../icons/editor/{help.svg => about.svg} | 0 resources/js/wysiwyg-tinymce/plugins-about.js | 2 +- .../wysiwyg/ui/defaults/buttons/controls.ts | 15 +- .../js/wysiwyg/ui/defaults/forms/controls.ts | 17 +- resources/js/wysiwyg/ui/defaults/modals.ts | 6 +- .../js/wysiwyg/ui/{ => defaults}/toolbars.ts | 39 +-- .../ui/framework/blocks/external-content.ts | 29 ++ resources/js/wysiwyg/ui/index.ts | 2 +- resources/sass/_editor.scss | 7 + resources/views/help/tinymce.blade.php | 146 +++++++++ resources/views/help/wysiwyg.blade.php | 280 +++++++++--------- routes/web.php | 1 + tests/Meta/HelpTest.php | 12 +- 14 files changed, 388 insertions(+), 170 deletions(-) rename resources/icons/editor/{help.svg => about.svg} (100%) rename resources/js/wysiwyg/ui/{ => defaults}/toolbars.ts (88%) create mode 100644 resources/js/wysiwyg/ui/framework/blocks/external-content.ts create mode 100644 resources/views/help/tinymce.blade.php diff --git a/lang/en/editor.php b/lang/en/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/en/editor.php +++ b/lang/en/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/resources/icons/editor/help.svg b/resources/icons/editor/about.svg similarity index 100% rename from resources/icons/editor/help.svg rename to resources/icons/editor/about.svg diff --git a/resources/js/wysiwyg-tinymce/plugins-about.js b/resources/js/wysiwyg-tinymce/plugins-about.js index 096b4f968..75cf476cf 100644 --- a/resources/js/wysiwyg-tinymce/plugins-about.js +++ b/resources/js/wysiwyg-tinymce/plugins-about.js @@ -4,7 +4,7 @@ function register(editor) { const aboutDialog = { title: 'About the WYSIWYG Editor', - url: window.baseUrl('/help/wysiwyg'), + url: window.baseUrl('/help/tinymce'), }; editor.ui.registry.addButton('about', { diff --git a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts index 77223dac3..5e3200539 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/controls.ts @@ -11,8 +11,9 @@ import { } from "lexical"; import redoIcon from "@icons/editor/redo.svg"; import sourceIcon from "@icons/editor/source-view.svg"; -import {getEditorContentAsHtml} from "../../../utils/actions"; import fullscreenIcon from "@icons/editor/fullscreen.svg"; +import aboutIcon from "@icons/editor/about.svg"; +import {getEditorContentAsHtml} from "../../../utils/actions"; export const undo: EditorButtonDefinition = { label: 'Undo', @@ -80,4 +81,16 @@ export const fullscreen: EditorButtonDefinition = { isActive(selection, context: EditorUiContext) { return context.containerDOM.classList.contains('fullscreen'); } +}; + +export const about: EditorButtonDefinition = { + label: 'About the editor', + icon: aboutIcon, + async action(context: EditorUiContext, button: EditorButton) { + const modal = context.manager.createModal('about'); + modal.show({}); + }, + isActive(selection, context: EditorUiContext) { + return false; + } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/forms/controls.ts b/resources/js/wysiwyg/ui/defaults/forms/controls.ts index fc461f662..8e7219d67 100644 --- a/resources/js/wysiwyg/ui/defaults/forms/controls.ts +++ b/resources/js/wysiwyg/ui/defaults/forms/controls.ts @@ -1,6 +1,7 @@ import {EditorFormDefinition} from "../../framework/forms"; -import {EditorUiContext} from "../../framework/core"; +import {EditorUiContext, EditorUiElement} from "../../framework/core"; import {setEditorContentFromHtml} from "../../../utils/actions"; +import {ExternalContent} from "../../framework/blocks/external-content"; export const source: EditorFormDefinition = { submitText: 'Save', @@ -15,4 +16,18 @@ export const source: EditorFormDefinition = { type: 'textarea', }, ], +}; + +export const about: EditorFormDefinition = { + submitText: 'Close', + async action() { + return true; + }, + fields: [ + { + build(): EditorUiElement { + return new ExternalContent('/help/wysiwyg'); + } + } + ], }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/modals.ts b/resources/js/wysiwyg/ui/defaults/modals.ts index da3859266..830a42935 100644 --- a/resources/js/wysiwyg/ui/defaults/modals.ts +++ b/resources/js/wysiwyg/ui/defaults/modals.ts @@ -1,6 +1,6 @@ import {EditorFormModalDefinition} from "../framework/modals"; import {details, image, link, media} from "./forms/objects"; -import {source} from "./forms/controls"; +import {about, source} from "./forms/controls"; import {cellProperties, rowProperties, tableProperties} from "./forms/tables"; export const modals: Record = { @@ -35,5 +35,9 @@ export const modals: Record = { details: { title: 'Edit collapsible block', form: details, + }, + about: { + title: 'About the WYSIWYG Editor', + form: about, } }; \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts similarity index 88% rename from resources/js/wysiwyg/ui/toolbars.ts rename to resources/js/wysiwyg/ui/defaults/toolbars.ts index 1230cbdd2..61baa3c32 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -1,12 +1,12 @@ -import {EditorButton} from "./framework/buttons"; -import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "./framework/core"; -import {EditorFormatMenu} from "./framework/blocks/format-menu"; -import {FormatPreviewButton} from "./framework/blocks/format-preview-button"; -import {EditorDropdownButton} from "./framework/blocks/dropdown-button"; -import {EditorColorPicker} from "./framework/blocks/color-picker"; -import {EditorTableCreator} from "./framework/blocks/table-creator"; -import {EditorColorButton} from "./framework/blocks/color-button"; -import {EditorOverflowContainer} from "./framework/blocks/overflow-container"; +import {EditorButton} from "../framework/buttons"; +import {EditorContainerUiElement, EditorSimpleClassContainer, EditorUiContext, EditorUiElement} from "../framework/core"; +import {EditorFormatMenu} from "../framework/blocks/format-menu"; +import {FormatPreviewButton} from "../framework/blocks/format-preview-button"; +import {EditorDropdownButton} from "../framework/blocks/dropdown-button"; +import {EditorColorPicker} from "../framework/blocks/color-picker"; +import {EditorTableCreator} from "../framework/blocks/table-creator"; +import {EditorColorButton} from "../framework/blocks/color-button"; +import {EditorOverflowContainer} from "../framework/blocks/overflow-container"; import { cellProperties, clearTableFormatting, copyColumn, @@ -29,8 +29,8 @@ import { rowProperties, splitCell, table, tableProperties -} from "./defaults/buttons/tables"; -import {fullscreen, redo, source, undo} from "./defaults/buttons/controls"; +} from "./buttons/tables"; +import {about, fullscreen, redo, source, undo} from "./buttons/controls"; import { blockquote, dangerCallout, h2, @@ -41,7 +41,7 @@ import { paragraph, successCallout, warningCallout -} from "./defaults/buttons/block-formats"; +} from "./buttons/block-formats"; import { bold, clearFormating, code, highlightColor, @@ -50,7 +50,7 @@ import { superscript, textColor, underline -} from "./defaults/buttons/inline-formats"; +} from "./buttons/inline-formats"; import { alignCenter, alignJustify, @@ -58,14 +58,14 @@ import { alignRight, directionLTR, directionRTL -} from "./defaults/buttons/alignments"; +} from "./buttons/alignments"; import { bulletList, indentDecrease, indentIncrease, numberList, taskList -} from "./defaults/buttons/lists"; +} from "./buttons/lists"; import { codeBlock, details, detailsEditLabel, detailsToggle, detailsUnwrap, @@ -75,10 +75,10 @@ import { image, link, media, unlink -} from "./defaults/buttons/objects"; -import {el} from "../utils/dom"; -import {EditorButtonWithMenu} from "./framework/blocks/button-with-menu"; -import {EditorSeparator} from "./framework/blocks/separator"; +} from "./buttons/objects"; +import {el} from "../../utils/dom"; +import {EditorButtonWithMenu} from "../framework/blocks/button-with-menu"; +import {EditorSeparator} from "../framework/blocks/separator"; export function getMainEditorFullToolbar(context: EditorUiContext): EditorContainerUiElement { @@ -201,6 +201,7 @@ export function getMainEditorFullToolbar(context: EditorUiContext): EditorContai // Meta elements new EditorOverflowContainer(3, [ new EditorButton(source), + new EditorButton(about), new EditorButton(fullscreen), // Test diff --git a/resources/js/wysiwyg/ui/framework/blocks/external-content.ts b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts new file mode 100644 index 000000000..b53c43e55 --- /dev/null +++ b/resources/js/wysiwyg/ui/framework/blocks/external-content.ts @@ -0,0 +1,29 @@ +import {EditorUiElement} from "../core"; +import {el} from "../../../utils/dom"; + +export class ExternalContent extends EditorUiElement { + + /** + * The URL for HTML to be loaded from. + */ + protected url: string = ''; + + constructor(url: string) { + super(); + this.url = url; + } + + buildDOM(): HTMLElement { + const wrapper = el('div', { + class: 'editor-external-content', + }); + + window.$http.get(this.url).then(resp => { + if (typeof resp.data === 'string') { + wrapper.innerHTML = resp.data; + } + }); + + return wrapper; + } +} diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 40df43347..fda37085e 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -4,7 +4,7 @@ import { getImageToolbarContent, getLinkToolbarContent, getMainEditorFullToolbar, getTableToolbarContent -} from "./toolbars"; +} from "./defaults/toolbars"; import {EditorUIManager} from "./framework/manager"; import {EditorUiContext} from "./framework/core"; import {CodeBlockDecorator} from "./decorators/code-block"; diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 3eef4c2c6..2446c1416 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -350,6 +350,13 @@ body.editor-is-fullscreen { text-align: center; padding: 0.2em; } +.editor-external-content { + min-width: 500px; + min-height: 500px; + h4:first-child { + margin-top: 0; + } +} // In-editor elements .editor-image-wrap { diff --git a/resources/views/help/tinymce.blade.php b/resources/views/help/tinymce.blade.php new file mode 100644 index 000000000..8ff59c8d6 --- /dev/null +++ b/resources/views/help/tinymce.blade.php @@ -0,0 +1,146 @@ +@extends('layouts.plain') +@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) + +@section('content') +
    + +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} +
    + {{ trans('editor.editor_tiny_license_link') }} +

    + +

    {{ trans('editor.shortcuts') }}

    + +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    + +
    +@endsection + diff --git a/resources/views/help/wysiwyg.blade.php b/resources/views/help/wysiwyg.blade.php index 8ff59c8d6..4fc00b0e1 100644 --- a/resources/views/help/wysiwyg.blade.php +++ b/resources/views/help/wysiwyg.blade.php @@ -1,146 +1,138 @@ -@extends('layouts.plain') -@section('document-class', 'bg-white ' . (setting()->getForCurrentUser('dark-mode-enabled') ? 'dark-mode ' : '')) +

    {{ trans('editor.shortcuts') }}

    -@section('content') -
    - -

    {{ trans('editor.editor_license') }}

    -

    - {!! trans('editor.editor_tiny_license', ['tinyLink' => 'TinyMCE']) !!} -
    - {{ trans('editor.editor_tiny_license_link') }} -

    - -

    {{ trans('editor.shortcuts') }}

    - -

    {{ trans('editor.shortcuts_intro') }}

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    - Ctrl+1
    - Ctrl+2
    - Ctrl+3
    - Ctrl+4 -
    - Cmd+1
    - Cmd+2
    - Cmd+3
    - Cmd+4 -
    - {{ trans('editor.header_large') }}
    - {{ trans('editor.header_medium') }}
    - {{ trans('editor.header_small') }}
    - {{ trans('editor.header_tiny') }} -
    - Ctrl+5
    - Ctrl+D -
    - Cmd+5
    - Cmd+D -
    {{ trans('editor.paragraph') }}
    - Ctrl+6
    - Ctrl+Q -
    - Cmd+6
    - Cmd+Q -
    {{ trans('editor.blockquote') }}
    - Ctrl+7
    - Ctrl+E -
    - Cmd+7
    - Cmd+E -
    {{ trans('editor.insert_code_block') }}
    - Ctrl+8
    - Ctrl+Shift+E -
    - Cmd+8
    - Cmd+Shift+E -
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 - {{ trans('editor.callouts') }}
    - {{ trans('editor.callouts_cycle') }} -
    - Ctrl+O
    - Ctrl+P -
    - Cmd+O
    - Cmd+P -
    - {{ trans('editor.list_numbered') }}
    - {{ trans('editor.list_bullet') }} -
    - Ctrl+Shift+K - - Cmd+Shift+K - {{ trans('editor.link_selector') }}
    - -
    -@endsection +

    {{ trans('editor.shortcuts_intro') }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    {{ trans('editor.shortcut') }} {{ trans('editor.windows_linux') }}{{ trans('editor.shortcut') }} {{ trans('editor.mac') }}{{ trans('editor.description') }}
    Ctrl+SCmd+S{{ trans('entities.pages_edit_save_draft') }}
    Ctrl+EnterCmd+Enter{{ trans('editor.save_continue') }}
    Ctrl+BCmd+B{{ trans('editor.bold') }}
    Ctrl+ICmd+I{{ trans('editor.italic') }}
    + Ctrl+1
    + Ctrl+2
    + Ctrl+3
    + Ctrl+4 +
    + Cmd+1
    + Cmd+2
    + Cmd+3
    + Cmd+4 +
    + {{ trans('editor.header_large') }}
    + {{ trans('editor.header_medium') }}
    + {{ trans('editor.header_small') }}
    + {{ trans('editor.header_tiny') }} +
    + Ctrl+5
    + Ctrl+D +
    + Cmd+5
    + Cmd+D +
    {{ trans('editor.paragraph') }}
    + Ctrl+6
    + Ctrl+Q +
    + Cmd+6
    + Cmd+Q +
    {{ trans('editor.blockquote') }}
    + Ctrl+7
    + Ctrl+E +
    + Cmd+7
    + Cmd+E +
    {{ trans('editor.insert_code_block') }}
    + Ctrl+8
    + Ctrl+Shift+E +
    + Cmd+8
    + Cmd+Shift+E +
    {{ trans('editor.inline_code') }}
    Ctrl+9Cmd+9 + {{ trans('editor.callouts') }}
    + {{ trans('editor.callouts_cycle') }} +
    + Ctrl+O
    + Ctrl+P +
    + Cmd+O
    + Cmd+P +
    + {{ trans('editor.list_numbered') }}
    + {{ trans('editor.list_bullet') }} +
    + Ctrl+Shift+K + + Cmd+Shift+K + {{ trans('editor.link_selector') }}
    +

    {{ trans('editor.editor_license') }}

    +

    + {!! trans('editor.editor_lexical_license', ['lexicalLink' => 'Lexical']) !!} +
    + Copyright (c) Meta Platforms, Inc. and affiliates. +
    + {{ trans('editor.editor_lexical_license_link') }} +

    \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 85f833528..318147ef5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -361,6 +361,7 @@ Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public'); // Metadata routes +Route::view('/help/tinymce', 'help.tinymce'); Route::view('/help/wysiwyg', 'help.wysiwyg'); Route::fallback([MetaController::class, 'notFound'])->name('fallback'); diff --git a/tests/Meta/HelpTest.php b/tests/Meta/HelpTest.php index e1de96bc8..acce65394 100644 --- a/tests/Meta/HelpTest.php +++ b/tests/Meta/HelpTest.php @@ -6,9 +6,9 @@ use Tests\TestCase; class HelpTest extends TestCase { - public function test_wysiwyg_help_shows_tiny_and_tiny_license_link() + public function test_tinymce_help_shows_tiny_and_tiny_license_link() { - $resp = $this->get('/help/wysiwyg'); + $resp = $this->get('/help/tinymce'); $resp->assertOk(); $this->withHtml($resp)->assertElementExists('a[href="https://www.tiny.cloud/"]'); $this->withHtml($resp)->assertElementExists('a[href="' . url('/libs/tinymce/license.txt') . '"]'); @@ -22,4 +22,12 @@ class HelpTest extends TestCase $contents = file_get_contents($expectedPath); $this->assertStringContainsString('MIT License', $contents); } + + public function test_wysiwyg_help_shows_lexical_and_licenses_link() + { + $resp = $this->get('/help/wysiwyg'); + $resp->assertOk(); + $this->withHtml($resp)->assertElementExists('a[href="https://lexical.dev/"]'); + $this->withHtml($resp)->assertElementExists('a[href="' . url('/licenses') . '"]'); + } } From 01825ddb9392212aef0b1bfba55e5191a7888119 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 21 Dec 2024 15:48:46 +0000 Subject: [PATCH 85/89] Dependancies: Bumped up composer dep versions --- composer.lock | 337 ++++++++++++++++++++++++-------------------------- 1 file changed, 165 insertions(+), 172 deletions(-) diff --git a/composer.lock b/composer.lock index f744f7620..2a71d3e7c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4a5a18010b7f4b32b7f0ae2a3e6305bb", + "content-hash": "9c0520d8b0c13ae46bd0213c4dec5e38", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.331.0", + "version": "3.336.2", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400" + "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f8b3f63ba7b296afedcb3e6a43ce140831b9400", - "reference": "0f8b3f63ba7b296afedcb3e6a43ce140831b9400", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/954bfdfc048840ca34afe2a2e1cbcff6681989c4", + "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4", "shasum": "" }, "require": { @@ -154,9 +154,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.331.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.336.2" }, - "time": "2024-11-27T19:12:58+00:00" + "time": "2024-12-20T19:05:10+00:00" }, { "name": "bacon/bacon-qr-code", @@ -674,29 +674,27 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.3", + "version": "1.1.4", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", + "doctrine/coding-standard": "^9 || ^12", + "phpstan/phpstan": "1.4.10 || 2.0.3", + "phpstan/phpstan-phpunit": "^1.0 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "psr/log": "^1 || ^2 || ^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -704,7 +702,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -715,9 +713,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + "source": "https://github.com/doctrine/deprecations/tree/1.1.4" }, - "time": "2024-01-30T19:34:25+00:00" + "time": "2024-12-07T21:18:45+00:00" }, { "name": "doctrine/event-manager", @@ -980,16 +978,16 @@ }, { "name": "dompdf/dompdf", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/dompdf/dompdf.git", - "reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59" + "reference": "2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/dompdf/zipball/fbc7c5ee5d94f7a910b78b43feb7931b7f971b59", - "reference": "fbc7c5ee5d94f7a910b78b43feb7931b7f971b59", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6", + "reference": "2d622faf9aa1f8f7f24dd094e49b5cf6c0c5d4e6", "shasum": "" }, "require": { @@ -1038,22 +1036,22 @@ "homepage": "https://github.com/dompdf/dompdf", "support": { "issues": "https://github.com/dompdf/dompdf/issues", - "source": "https://github.com/dompdf/dompdf/tree/v3.0.0" + "source": "https://github.com/dompdf/dompdf/tree/v3.0.1" }, - "time": "2024-04-29T14:01:28+00:00" + "time": "2024-12-05T14:59:38+00:00" }, { "name": "dompdf/php-font-lib", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/dompdf/php-font-lib.git", - "reference": "991d6a954f6bbd7e41022198f00586b230731441" + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/991d6a954f6bbd7e41022198f00586b230731441", - "reference": "991d6a954f6bbd7e41022198f00586b230731441", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", + "reference": "6137b7d4232b7f16c882c75e4ca3991dbcf6fe2d", "shasum": "" }, "require": { @@ -1083,9 +1081,9 @@ "homepage": "https://github.com/dompdf/php-font-lib", "support": { "issues": "https://github.com/dompdf/php-font-lib/issues", - "source": "https://github.com/dompdf/php-font-lib/tree/1.0.0" + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.1" }, - "time": "2024-04-29T13:40:38+00:00" + "time": "2024-12-02T14:37:59+00:00" }, { "name": "dompdf/php-svg-lib", @@ -1942,16 +1940,16 @@ }, { "name": "intervention/image", - "version": "3.9.1", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683" + "reference": "1ddc9a096b3a641958515700e09be910bf03a5bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/b496d1f6b9f812f96166623358dfcafb8c3b1683", - "reference": "b496d1f6b9f812f96166623358dfcafb8c3b1683", + "url": "https://api.github.com/repos/Intervention/image/zipball/1ddc9a096b3a641958515700e09be910bf03a5bd", + "reference": "1ddc9a096b3a641958515700e09be910bf03a5bd", "shasum": "" }, "require": { @@ -1961,8 +1959,8 @@ }, "require-dev": { "mockery/mockery": "^1.6", - "phpstan/phpstan": "^1", - "phpunit/phpunit": "^10.0", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10.0 || ^11.0", "slevomat/coding-standard": "~8.0", "squizlabs/php_codesniffer": "^3.8" }, @@ -1998,7 +1996,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.9.1" + "source": "https://github.com/Intervention/image/tree/3.10.0" }, "funding": [ { @@ -2014,7 +2012,7 @@ "type": "ko_fi" } ], - "time": "2024-10-27T10:15:54+00:00" + "time": "2024-12-21T07:41:40+00:00" }, { "name": "knplabs/knp-snappy", @@ -2411,16 +2409,16 @@ }, { "name": "laravel/socialite", - "version": "v5.16.0", + "version": "v5.16.1", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf" + "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf", - "reference": "40a2dc98c53d9dc6d55eadb0d490d3d72b73f1bf", + "url": "https://api.github.com/repos/laravel/socialite/zipball/4e5be83c0b3ecf81b2ffa47092e917d1f79dce71", + "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71", "shasum": "" }, "require": { @@ -2430,7 +2428,7 @@ "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "league/oauth1-client": "^1.10.1", + "league/oauth1-client": "^1.11", "php": "^7.2|^8.0", "phpseclib/phpseclib": "^3.0" }, @@ -2442,16 +2440,16 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - }, "laravel": { - "providers": [ - "Laravel\\Socialite\\SocialiteServiceProvider" - ], "aliases": { "Socialite": "Laravel\\Socialite\\Facades\\Socialite" - } + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" } }, "autoload": { @@ -2479,7 +2477,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2024-09-03T09:46:57+00:00" + "time": "2024-12-11T16:43:51+00:00" }, { "name": "laravel/tinker", @@ -2549,16 +2547,16 @@ }, { "name": "league/commonmark", - "version": "2.5.3", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "b650144166dfa7703e62a22e493b853b58d874b0" + "reference": "d150f911e0079e90ae3c106734c93137c184f932" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/b650144166dfa7703e62a22e493b853b58d874b0", - "reference": "b650144166dfa7703e62a22e493b853b58d874b0", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d150f911e0079e90ae3c106734c93137c184f932", + "reference": "d150f911e0079e90ae3c106734c93137c184f932", "shasum": "" }, "require": { @@ -2583,8 +2581,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 || ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 || ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0" }, @@ -2594,7 +2593,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.6-dev" + "dev-main": "2.7-dev" } }, "autoload": { @@ -2651,7 +2650,7 @@ "type": "tidelift" } ], - "time": "2024-08-16T11:46:16+00:00" + "time": "2024-12-07T15:34:16+00:00" }, { "name": "league/config", @@ -3069,16 +3068,16 @@ }, { "name": "league/oauth1-client", - "version": "v1.10.1", + "version": "v1.11.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth1-client.git", - "reference": "d6365b901b5c287dd41f143033315e2f777e1167" + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/d6365b901b5c287dd41f143033315e2f777e1167", - "reference": "d6365b901b5c287dd41f143033315e2f777e1167", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", "shasum": "" }, "require": { @@ -3139,41 +3138,36 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth1-client/issues", - "source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.1" + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" }, - "time": "2022-04-15T14:02:14+00:00" + "time": "2024-12-10T19:59:05+00:00" }, { "name": "league/oauth2-client", - "version": "2.7.0", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/thephpleague/oauth2-client.git", - "reference": "160d6274b03562ebeb55ed18399281d8118b76c8" + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/160d6274b03562ebeb55ed18399281d8118b76c8", - "reference": "160d6274b03562ebeb55ed18399281d8118b76c8", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/3d5cf8d0543731dfb725ab30e4d7289891991e13", + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^6.0 || ^7.0", - "paragonie/random_compat": "^1 || ^2 || ^9.99", - "php": "^5.6 || ^7.0 || ^8.0" + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.5.0" }, "require-dev": { "mockery/mockery": "^1.3.5", - "php-parallel-lint/php-parallel-lint": "^1.3.1", - "phpunit/phpunit": "^5.7 || ^6.0 || ^9.5", - "squizlabs/php_codesniffer": "^2.3 || ^3.0" + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.0.x-dev" - } - }, "autoload": { "psr-4": { "League\\OAuth2\\Client\\": "src/" @@ -3209,9 +3203,9 @@ ], "support": { "issues": "https://github.com/thephpleague/oauth2-client/issues", - "source": "https://github.com/thephpleague/oauth2-client/tree/2.7.0" + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.0" }, - "time": "2023-04-16T18:19:15+00:00" + "time": "2024-12-11T05:05:52+00:00" }, { "name": "masterminds/html5", @@ -3282,16 +3276,16 @@ }, { "name": "monolog/monolog", - "version": "3.8.0", + "version": "3.8.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67" + "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/32e515fdc02cdafbe4593e30a9350d486b125b67", - "reference": "32e515fdc02cdafbe4593e30a9350d486b125b67", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/aef6ee73a77a66e404dd6540934a9ef1b3c855b4", + "reference": "aef6ee73a77a66e404dd6540934a9ef1b3c855b4", "shasum": "" }, "require": { @@ -3369,7 +3363,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.8.0" + "source": "https://github.com/Seldaek/monolog/tree/3.8.1" }, "funding": [ { @@ -3381,7 +3375,7 @@ "type": "tidelift" } ], - "time": "2024-11-12T13:57:08+00:00" + "time": "2024-12-05T17:15:07+00:00" }, { "name": "mtdowling/jmespath.php", @@ -3493,10 +3487,6 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.x-dev", - "dev-2.x": "2.x-dev" - }, "laravel": { "providers": [ "Carbon\\Laravel\\ServiceProvider" @@ -3506,6 +3496,10 @@ "includes": [ "extension.neon" ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" } }, "autoload": { @@ -4105,16 +4099,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.42", + "version": "3.0.43", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98" + "reference": "709ec107af3cb2f385b9617be72af8cf62441d02" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db92f1b1987b12b13f248fe76c3a52cadb67bb98", - "reference": "db92f1b1987b12b13f248fe76c3a52cadb67bb98", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02", + "reference": "709ec107af3cb2f385b9617be72af8cf62441d02", "shasum": "" }, "require": { @@ -4195,7 +4189,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.42" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43" }, "funding": [ { @@ -4211,7 +4205,7 @@ "type": "tidelift" } ], - "time": "2024-09-16T03:06:04+00:00" + "time": "2024-12-14T21:12:59+00:00" }, { "name": "pragmarx/google2fa", @@ -4789,16 +4783,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.4", + "version": "v0.12.7", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "2fd717afa05341b4f8152547f142cd2f130f6818" + "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/2fd717afa05341b4f8152547f142cd2f130f6818", - "reference": "2fd717afa05341b4f8152547f142cd2f130f6818", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", + "reference": "d73fa3c74918ef4522bb8a3bf9cab39161c4b57c", "shasum": "" }, "require": { @@ -4825,12 +4819,12 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-main": "0.12.x-dev" - }, "bamarni-bin": { "bin-links": false, "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" } }, "autoload": { @@ -4862,9 +4856,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.4" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.7" }, - "time": "2024-06-10T01:18:23+00:00" + "time": "2024-12-10T01:58:33+00:00" }, { "name": "ralouphie/getallheaders", @@ -5516,17 +5510,15 @@ }, { "name": "ssddanbrown/htmldiff", - "version": "v1.0.3", + "version": "v1.0.4", "source": { "type": "git", - "url": "https://github.com/ssddanbrown/HtmlDiff.git", - "reference": "92da405f8138066834b71ac7bedebbda6327761b" + "url": "https://codeberg.org/danb/HtmlDiff", + "reference": "d5cbd43f66c4e512cc0ab71d0e0b07271e7d6af6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/92da405f8138066834b71ac7bedebbda6327761b", - "reference": "92da405f8138066834b71ac7bedebbda6327761b", - "shasum": "" + "url": "https://codeberg.org/api/v1/repos/danb/HtmlDiff/archive/%prettyVersion%.zip" }, "require": { "ext-mbstring": "*", @@ -5549,23 +5541,23 @@ "authors": [ { "name": "Dan Brown", - "email": "ssddanbrown@googlemail.com", + "homepage": "https://danb.me", "role": "Developer" } ], "description": "HTML Content Diff Generator", - "homepage": "https://github.com/ssddanbrown/htmldiff", - "support": { - "issues": "https://github.com/ssddanbrown/HtmlDiff/issues", - "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.3" - }, + "homepage": "https://codeberg.org/danb/HtmlDiff", "funding": [ { - "url": "https://github.com/ssddanbrown", + "url": "https://github.com/sponsors/ssddanbrown", "type": "github" + }, + { + "url": "https://ko-fi.com/ssddanbrown", + "type": "kofi" } ], - "time": "2024-03-29T16:51:55+00:00" + "time": "2024-12-12T16:45:37+00:00" }, { "name": "ssddanbrown/symfony-mailer", @@ -6464,8 +6456,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6540,8 +6532,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6619,8 +6611,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6701,8 +6693,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6785,8 +6777,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6859,8 +6851,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -6939,8 +6931,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -7021,8 +7013,8 @@ "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -8177,16 +8169,16 @@ }, { "name": "itsgoingd/clockwork", - "version": "v5.3.1", + "version": "v5.3.2", "source": { "type": "git", "url": "https://github.com/itsgoingd/clockwork.git", - "reference": "7b0c40418df761f7a78e88762a323386a139d83d" + "reference": "ffd1f1626830005e92461a538ad58372641e065a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/7b0c40418df761f7a78e88762a323386a139d83d", - "reference": "7b0c40418df761f7a78e88762a323386a139d83d", + "url": "https://api.github.com/repos/itsgoingd/clockwork/zipball/ffd1f1626830005e92461a538ad58372641e065a", + "reference": "ffd1f1626830005e92461a538ad58372641e065a", "shasum": "" }, "require": { @@ -8204,12 +8196,12 @@ "type": "library", "extra": { "laravel": { - "providers": [ - "Clockwork\\Support\\Laravel\\ClockworkServiceProvider" - ], "aliases": { "Clockwork": "Clockwork\\Support\\Laravel\\Facade" - } + }, + "providers": [ + "Clockwork\\Support\\Laravel\\ClockworkServiceProvider" + ] } }, "autoload": { @@ -8241,7 +8233,7 @@ ], "support": { "issues": "https://github.com/itsgoingd/clockwork/issues", - "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.1" + "source": "https://github.com/itsgoingd/clockwork/tree/v5.3.2" }, "funding": [ { @@ -8249,7 +8241,7 @@ "type": "github" } ], - "time": "2024-11-19T17:25:22+00:00" + "time": "2024-12-02T22:59:59+00:00" }, { "name": "larastan/larastan", @@ -8703,16 +8695,16 @@ }, { "name": "phpmyadmin/sql-parser", - "version": "5.10.1", + "version": "5.10.2", "source": { "type": "git", "url": "https://github.com/phpmyadmin/sql-parser.git", - "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17" + "reference": "72afbce7e4b421593b60d2eb7281e37a50734df8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/b14fd66496a22d8dd7f7e2791edd9e8674422f17", - "reference": "b14fd66496a22d8dd7f7e2791edd9e8674422f17", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/72afbce7e4b421593b60d2eb7281e37a50734df8", + "reference": "72afbce7e4b421593b60d2eb7281e37a50734df8", "shasum": "" }, "require": { @@ -8786,20 +8778,20 @@ "type": "other" } ], - "time": "2024-11-10T04:10:31+00:00" + "time": "2024-12-05T15:04:09+00:00" }, { "name": "phpstan/phpstan", - "version": "1.12.11", + "version": "1.12.13", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733" + "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", - "reference": "0d1fc20a962a91be578bcfe7cf939e6e1a2ff733", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", + "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", "shasum": "" }, "require": { @@ -8844,7 +8836,7 @@ "type": "github" } ], - "time": "2024-11-17T14:08:01+00:00" + "time": "2024-12-17T17:00:20+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9169,16 +9161,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.38", + "version": "10.5.40", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132" + "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a86773b9e887a67bc53efa9da9ad6e3f2498c132", - "reference": "a86773b9e887a67bc53efa9da9ad6e3f2498c132", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6ddda95af52f69c1e0c7b4f977cccb58048798c", + "reference": "e6ddda95af52f69c1e0c7b4f977cccb58048798c", "shasum": "" }, "require": { @@ -9188,7 +9180,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", + "myclabs/deep-copy": "^1.12.1", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -9250,7 +9242,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.38" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.40" }, "funding": [ { @@ -9266,7 +9258,7 @@ "type": "tidelift" } ], - "time": "2024-10-28T13:06:21+00:00" + "time": "2024-12-21T05:49:06+00:00" }, { "name": "sebastian/cli-parser", @@ -10186,16 +10178,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.11.1", + "version": "3.11.2", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87" + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", - "reference": "19473c30efe4f7b3cd42522d0b2e6e7f243c6f87", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/1368f4a58c3c52114b86b1abe8f4098869cb0079", + "reference": "1368f4a58c3c52114b86b1abe8f4098869cb0079", "shasum": "" }, "require": { @@ -10262,7 +10254,7 @@ "type": "open_collective" } ], - "time": "2024-11-16T12:02:36+00:00" + "time": "2024-12-11T16:04:26+00:00" }, { "name": "ssddanbrown/asserthtml", @@ -10453,7 +10445,8 @@ "ext-gd": "*", "ext-json": "*", "ext-mbstring": "*", - "ext-xml": "*" + "ext-xml": "*", + "ext-zip": "*" }, "platform-dev": {}, "platform-overrides": { From c84d99945649941c82b654ec4247f56cfa98a81e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 22 Dec 2024 12:43:26 +0000 Subject: [PATCH 86/89] ZIP Exports: Prevent book child page drafts from being included Added test to cover --- .../ZipExports/Models/ZipExportBook.php | 2 +- tests/Exports/ZipExportTest.php | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 4f641d25b..39176ded4 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -70,7 +70,7 @@ class ZipExportBook extends ZipExportModel foreach ($children as $child) { if ($child instanceof Chapter) { $chapters[] = $child; - } else if ($child instanceof Page) { + } else if ($child instanceof Page && !$child->draft) { $pages[] = $child; } } diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index ebe07d052..163828c1b 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -198,7 +198,7 @@ class ZipExportTest extends TestCase public function test_book_export() { - $book = $this->entities->book(); + $book = $this->entities->bookHasChaptersAndPages(); $book->tags()->saveMany(Tag::factory()->count(2)->make()); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); @@ -251,6 +251,35 @@ class ZipExportTest extends TestCase $this->assertCount($chapter->pages()->count(), $chapterData['pages']); } + public function test_draft_pages_are_not_included() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $book = $entities['book']; + $page = $entities['page']; + $chapter = $entities['chapter']; + $book->tags()->saveMany(Tag::factory()->count(2)->make()); + + $page->created_by = $editor->id; + $page->draft = true; + $page->save(); + + $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertCount(0, $zip->data['book']['chapters'][0]['pages'] ?? ['cat']); + + $zipResp = $this->actingAs($editor)->get($chapter->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertCount(0, $zip->data['chapter']['pages'] ?? ['cat']); + + $page->chapter_id = 0; + $page->save(); + + $zipResp = $this->actingAs($editor)->get($book->getUrl("/export/zip")); + $zip = $this->extractZipResponse($zipResp); + $this->assertCount(0, $zip->data['book']['pages'] ?? ['cat']); + } + public function test_cross_reference_links_are_converted() { From 2be504e0d20aa7c95b1fe006f675928faf0bd1a9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Dec 2024 11:23:44 +0000 Subject: [PATCH 87/89] Updated translations with latest Crowdin changes (#5345) --- lang/ar/activities.php | 8 + lang/ar/editor.php | 2 + lang/ar/entities.php | 21 ++ lang/ar/errors.php | 12 + lang/ar/settings.php | 1 + lang/ar/validation.php | 5 + lang/bg/activities.php | 8 + lang/bg/editor.php | 2 + lang/bg/entities.php | 21 ++ lang/bg/errors.php | 12 + lang/bg/settings.php | 1 + lang/bg/validation.php | 5 + lang/bn/activities.php | 132 +++++++++ lang/bn/auth.php | 117 ++++++++ lang/bn/common.php | 113 ++++++++ lang/bn/components.php | 46 ++++ lang/bn/editor.php | 179 +++++++++++++ lang/bn/entities.php | 460 ++++++++++++++++++++++++++++++++ lang/bn/errors.php | 133 +++++++++ lang/bn/notifications.php | 27 ++ lang/bn/pagination.php | 12 + lang/bn/passwords.php | 15 ++ lang/bn/preferences.php | 51 ++++ lang/bn/settings.php | 339 +++++++++++++++++++++++ lang/bn/validation.php | 122 +++++++++ lang/bs/activities.php | 8 + lang/bs/editor.php | 2 + lang/bs/entities.php | 21 ++ lang/bs/errors.php | 12 + lang/bs/settings.php | 1 + lang/bs/validation.php | 5 + lang/ca/activities.php | 8 + lang/ca/editor.php | 2 + lang/ca/entities.php | 21 ++ lang/ca/errors.php | 12 + lang/ca/settings.php | 1 + lang/ca/validation.php | 5 + lang/cs/activities.php | 8 + lang/cs/editor.php | 2 + lang/cs/entities.php | 21 ++ lang/cs/errors.php | 12 + lang/cs/settings.php | 1 + lang/cs/validation.php | 5 + lang/cy/activities.php | 8 + lang/cy/editor.php | 2 + lang/cy/entities.php | 21 ++ lang/cy/errors.php | 12 + lang/cy/settings.php | 1 + lang/cy/validation.php | 5 + lang/da/activities.php | 8 + lang/da/editor.php | 2 + lang/da/entities.php | 21 ++ lang/da/errors.php | 12 + lang/da/settings.php | 1 + lang/da/validation.php | 5 + lang/de/activities.php | 8 + lang/de/editor.php | 2 + lang/de/entities.php | 21 ++ lang/de/errors.php | 12 + lang/de/settings.php | 1 + lang/de/validation.php | 5 + lang/de_informal/activities.php | 8 + lang/de_informal/common.php | 2 +- lang/de_informal/editor.php | 2 + lang/de_informal/entities.php | 25 +- lang/de_informal/errors.php | 14 +- lang/de_informal/settings.php | 1 + lang/de_informal/validation.php | 7 +- lang/el/activities.php | 26 +- lang/el/auth.php | 2 +- lang/el/common.php | 10 +- lang/el/components.php | 26 +- lang/el/editor.php | 12 +- lang/el/entities.php | 21 ++ lang/el/errors.php | 12 + lang/el/notifications.php | 34 +-- lang/el/settings.php | 31 +-- lang/el/validation.php | 5 + lang/es/activities.php | 8 + lang/es/editor.php | 2 + lang/es/entities.php | 21 ++ lang/es/errors.php | 12 + lang/es/settings.php | 1 + lang/es/validation.php | 5 + lang/es_AR/activities.php | 8 + lang/es_AR/editor.php | 2 + lang/es_AR/entities.php | 21 ++ lang/es_AR/errors.php | 12 + lang/es_AR/settings.php | 1 + lang/es_AR/validation.php | 5 + lang/et/activities.php | 8 + lang/et/editor.php | 2 + lang/et/entities.php | 21 ++ lang/et/errors.php | 12 + lang/et/settings.php | 1 + lang/et/validation.php | 5 + lang/eu/activities.php | 8 + lang/eu/editor.php | 2 + lang/eu/entities.php | 21 ++ lang/eu/errors.php | 12 + lang/eu/settings.php | 1 + lang/eu/validation.php | 5 + lang/fa/activities.php | 8 + lang/fa/editor.php | 2 + lang/fa/entities.php | 21 ++ lang/fa/errors.php | 12 + lang/fa/settings.php | 1 + lang/fa/validation.php | 5 + lang/fi/activities.php | 8 + lang/fi/editor.php | 2 + lang/fi/entities.php | 21 ++ lang/fi/errors.php | 12 + lang/fi/settings.php | 1 + lang/fi/validation.php | 5 + lang/fr/activities.php | 8 + lang/fr/editor.php | 2 + lang/fr/entities.php | 21 ++ lang/fr/errors.php | 12 + lang/fr/settings.php | 1 + lang/fr/validation.php | 5 + lang/he/activities.php | 8 + lang/he/editor.php | 2 + lang/he/entities.php | 21 ++ lang/he/errors.php | 12 + lang/he/settings.php | 1 + lang/he/validation.php | 5 + lang/hr/activities.php | 8 + lang/hr/editor.php | 2 + lang/hr/entities.php | 21 ++ lang/hr/errors.php | 12 + lang/hr/settings.php | 1 + lang/hr/validation.php | 5 + lang/hu/activities.php | 8 + lang/hu/editor.php | 2 + lang/hu/entities.php | 21 ++ lang/hu/errors.php | 12 + lang/hu/settings.php | 1 + lang/hu/validation.php | 5 + lang/id/activities.php | 8 + lang/id/editor.php | 2 + lang/id/entities.php | 21 ++ lang/id/errors.php | 12 + lang/id/settings.php | 1 + lang/id/validation.php | 5 + lang/is/activities.php | 132 +++++++++ lang/is/auth.php | 117 ++++++++ lang/is/common.php | 113 ++++++++ lang/is/components.php | 46 ++++ lang/is/editor.php | 179 +++++++++++++ lang/is/entities.php | 460 ++++++++++++++++++++++++++++++++ lang/is/errors.php | 133 +++++++++ lang/is/notifications.php | 27 ++ lang/is/pagination.php | 12 + lang/is/passwords.php | 15 ++ lang/is/preferences.php | 51 ++++ lang/is/settings.php | 339 +++++++++++++++++++++++ lang/is/validation.php | 122 +++++++++ lang/it/activities.php | 8 + lang/it/editor.php | 2 + lang/it/entities.php | 21 ++ lang/it/errors.php | 12 + lang/it/settings.php | 1 + lang/it/validation.php | 5 + lang/ja/activities.php | 8 + lang/ja/editor.php | 2 + lang/ja/entities.php | 21 ++ lang/ja/errors.php | 12 + lang/ja/settings.php | 1 + lang/ja/validation.php | 5 + lang/ka/activities.php | 8 + lang/ka/editor.php | 2 + lang/ka/entities.php | 21 ++ lang/ka/errors.php | 12 + lang/ka/settings.php | 1 + lang/ka/validation.php | 5 + lang/ko/activities.php | 10 +- lang/ko/common.php | 4 +- lang/ko/components.php | 6 +- lang/ko/editor.php | 2 + lang/ko/entities.php | 37 ++- lang/ko/errors.php | 12 + lang/ko/settings.php | 1 + lang/ko/validation.php | 5 + lang/lt/activities.php | 8 + lang/lt/editor.php | 2 + lang/lt/entities.php | 21 ++ lang/lt/errors.php | 12 + lang/lt/settings.php | 1 + lang/lt/validation.php | 5 + lang/lv/activities.php | 8 + lang/lv/editor.php | 2 + lang/lv/entities.php | 21 ++ lang/lv/errors.php | 12 + lang/lv/settings.php | 1 + lang/lv/validation.php | 5 + lang/nb/activities.php | 8 + lang/nb/common.php | 2 +- lang/nb/editor.php | 2 + lang/nb/entities.php | 25 +- lang/nb/errors.php | 14 +- lang/nb/settings.php | 1 + lang/nb/validation.php | 5 + lang/nl/activities.php | 8 + lang/nl/editor.php | 2 + lang/nl/entities.php | 21 ++ lang/nl/errors.php | 12 + lang/nl/settings.php | 1 + lang/nl/validation.php | 5 + lang/nn/activities.php | 8 + lang/nn/editor.php | 2 + lang/nn/entities.php | 21 ++ lang/nn/errors.php | 12 + lang/nn/settings.php | 1 + lang/nn/validation.php | 5 + lang/pl/activities.php | 8 + lang/pl/editor.php | 2 + lang/pl/entities.php | 21 ++ lang/pl/errors.php | 12 + lang/pl/settings.php | 1 + lang/pl/validation.php | 5 + lang/pt/activities.php | 8 + lang/pt/editor.php | 2 + lang/pt/entities.php | 21 ++ lang/pt/errors.php | 12 + lang/pt/settings.php | 1 + lang/pt/validation.php | 5 + lang/pt_BR/activities.php | 8 + lang/pt_BR/editor.php | 2 + lang/pt_BR/entities.php | 23 +- lang/pt_BR/errors.php | 12 + lang/pt_BR/settings.php | 1 + lang/pt_BR/validation.php | 5 + lang/ro/activities.php | 8 + lang/ro/editor.php | 2 + lang/ro/entities.php | 21 ++ lang/ro/errors.php | 12 + lang/ro/settings.php | 1 + lang/ro/validation.php | 5 + lang/ru/activities.php | 8 + lang/ru/editor.php | 2 + lang/ru/entities.php | 21 ++ lang/ru/errors.php | 12 + lang/ru/settings.php | 1 + lang/ru/validation.php | 5 + lang/sk/activities.php | 8 + lang/sk/editor.php | 2 + lang/sk/entities.php | 21 ++ lang/sk/errors.php | 12 + lang/sk/settings.php | 1 + lang/sk/validation.php | 5 + lang/sl/activities.php | 8 + lang/sl/editor.php | 2 + lang/sl/entities.php | 21 ++ lang/sl/errors.php | 12 + lang/sl/settings.php | 1 + lang/sl/validation.php | 5 + lang/sq/activities.php | 8 + lang/sq/editor.php | 2 + lang/sq/entities.php | 21 ++ lang/sq/errors.php | 12 + lang/sq/settings.php | 1 + lang/sq/validation.php | 5 + lang/sr/activities.php | 8 + lang/sr/editor.php | 2 + lang/sr/entities.php | 21 ++ lang/sr/errors.php | 12 + lang/sr/settings.php | 1 + lang/sr/validation.php | 5 + lang/sv/activities.php | 8 + lang/sv/editor.php | 2 + lang/sv/entities.php | 21 ++ lang/sv/errors.php | 12 + lang/sv/settings.php | 1 + lang/sv/validation.php | 5 + lang/tk/activities.php | 8 + lang/tk/editor.php | 2 + lang/tk/entities.php | 21 ++ lang/tk/errors.php | 12 + lang/tk/settings.php | 1 + lang/tk/validation.php | 5 + lang/tr/activities.php | 8 + lang/tr/editor.php | 2 + lang/tr/entities.php | 21 ++ lang/tr/errors.php | 12 + lang/tr/settings.php | 1 + lang/tr/validation.php | 5 + lang/uk/activities.php | 8 + lang/uk/editor.php | 2 + lang/uk/entities.php | 21 ++ lang/uk/errors.php | 12 + lang/uk/settings.php | 1 + lang/uk/validation.php | 5 + lang/uz/activities.php | 8 + lang/uz/editor.php | 2 + lang/uz/entities.php | 21 ++ lang/uz/errors.php | 12 + lang/uz/settings.php | 1 + lang/uz/validation.php | 5 + lang/vi/activities.php | 8 + lang/vi/editor.php | 2 + lang/vi/entities.php | 21 ++ lang/vi/errors.php | 12 + lang/vi/settings.php | 1 + lang/vi/validation.php | 5 + lang/zh_CN/activities.php | 8 + lang/zh_CN/components.php | 2 +- lang/zh_CN/editor.php | 2 + lang/zh_CN/entities.php | 21 ++ lang/zh_CN/errors.php | 12 + lang/zh_CN/settings.php | 1 + lang/zh_CN/validation.php | 5 + lang/zh_TW/activities.php | 8 + lang/zh_TW/common.php | 2 +- lang/zh_TW/editor.php | 2 + lang/zh_TW/entities.php | 25 +- lang/zh_TW/errors.php | 14 +- lang/zh_TW/settings.php | 1 + lang/zh_TW/validation.php | 5 + 318 files changed, 5889 insertions(+), 94 deletions(-) create mode 100644 lang/bn/activities.php create mode 100644 lang/bn/auth.php create mode 100644 lang/bn/common.php create mode 100644 lang/bn/components.php create mode 100644 lang/bn/editor.php create mode 100644 lang/bn/entities.php create mode 100644 lang/bn/errors.php create mode 100644 lang/bn/notifications.php create mode 100644 lang/bn/pagination.php create mode 100644 lang/bn/passwords.php create mode 100644 lang/bn/preferences.php create mode 100644 lang/bn/settings.php create mode 100644 lang/bn/validation.php create mode 100644 lang/is/activities.php create mode 100644 lang/is/auth.php create mode 100644 lang/is/common.php create mode 100644 lang/is/components.php create mode 100644 lang/is/editor.php create mode 100644 lang/is/entities.php create mode 100644 lang/is/errors.php create mode 100644 lang/is/notifications.php create mode 100644 lang/is/pagination.php create mode 100644 lang/is/passwords.php create mode 100644 lang/is/preferences.php create mode 100644 lang/is/settings.php create mode 100644 lang/is/validation.php diff --git a/lang/ar/activities.php b/lang/ar/activities.php index 134dce4cd..127ddbe1a 100644 --- a/lang/ar/activities.php +++ b/lang/ar/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'حذف webhook', 'webhook_delete_notification' => 'تم حذف Webhook بنجاح', + // 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' => 'إنشاء مستخدم', 'user_create_notification' => 'تم انشاء الحساب', diff --git a/lang/ar/editor.php b/lang/ar/editor.php index 6fb6e3d11..2254eb9ba 100644 --- a/lang/ar/editor.php +++ b/lang/ar/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/ar/entities.php b/lang/ar/entities.php index 1740ca9f9..065516bcf 100644 --- a/lang/ar/entities.php +++ b/lang/ar/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'ملف PDF', 'export_text' => 'ملف نص عادي', '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'الأذونات', diff --git a/lang/ar/errors.php b/lang/ar/errors.php index 7788c54ca..73e76a5cc 100644 --- a/lang/ar/errors.php +++ b/lang/ar/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName لا يعمل حالياً', 'back_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.', + '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.', + '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' => 'لم يتم العثور على رمز ترخيص مميز في الطلب', 'api_bad_authorization_format' => 'تم العثور على رمز ترخيص مميز في الطلب ولكن يبدو أن التنسيق غير صحيح', diff --git a/lang/ar/settings.php b/lang/ar/settings.php index 0d8ebcfea..ca8a7d909 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'الوصول إلى واجهة برمجة تطبيقات النظام API', 'role_manage_settings' => 'إدارة إعدادات التطبيق', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'أذونات الأصول', diff --git a/lang/ar/validation.php b/lang/ar/validation.php index a9793c548..f35c92034 100644 --- a/lang/ar/validation.php +++ b/lang/ar/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'صيغة :attribute غير صالحة.', 'uploaded' => 'تعذر تحميل الملف. قد لا يقبل الخادم ملفات بهذا الحجم.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/bg/activities.php b/lang/bg/activities.php index e1e43a0d2..d7ac37c10 100644 --- a/lang/bg/activities.php +++ b/lang/bg/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'изтрита уебкука', 'webhook_delete_notification' => 'Уебкуката е изтрита успешно', + // 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/bg/editor.php b/lang/bg/editor.php index 5f6fa1a6a..a53082038 100644 --- a/lang/bg/editor.php +++ b/lang/bg/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'За редактора', 'about_title' => 'Относно визуалния редактор', 'editor_license' => 'Лиценз, авторски и сходни права на редактора', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Този редактор е изграден посредством :tinyLink, което е предоставен под лиценз MIT.', 'editor_tiny_license_link' => 'Авторското и сходните му права, както и лицензът на TinyMCE, могат да бъдат намерени тук.', 'save_continue' => 'Запази страницата и продължи', diff --git a/lang/bg/entities.php b/lang/bg/entities.php index 468256d7b..1fdb3f634 100644 --- a/lang/bg/entities.php +++ b/lang/bg/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF файл', 'export_text' => 'Обикновен текстов файл', 'export_md' => 'Markdown файл', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Права', diff --git a/lang/bg/errors.php b/lang/bg/errors.php index 73909b344..dd0245180 100644 --- a/lang/bg/errors.php +++ b/lang/bg/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName не е достъпно в момента', 'back_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.', + '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.', + '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' => 'Но беше намерен код за достъп в заявката', 'api_bad_authorization_format' => 'В заявката имаше код за достъп, но формата изглежда е неправилен', diff --git a/lang/bg/settings.php b/lang/bg/settings.php index 7faca6922..1bc617fc9 100644 --- a/lang/bg/settings.php +++ b/lang/bg/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Достъп до API на системата', 'role_manage_settings' => 'Управление на настройките на приложението', 'role_export_content' => 'Експортирай съдържанието', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Настройки за достъп до активи', diff --git a/lang/bg/validation.php b/lang/bg/validation.php index fb6c4dd4a..e08eb55de 100644 --- a/lang/bg/validation.php +++ b/lang/bg/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Форматът на :attribute не е валиден.', 'uploaded' => 'Файлът не можа да бъде качен. Сървърът може да не приема файлове с такъв размер.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/bn/activities.php b/lang/bn/activities.php new file mode 100644 index 000000000..e46d85fbb --- /dev/null +++ b/lang/bn/activities.php @@ -0,0 +1,132 @@ + 'নতুন পৃষ্ঠা সৃষ্টি করেছেন', + 'page_create_notification' => 'পৃষ্ঠাটি সার্থকভাবে তৈরী করা হয়েছে', + 'page_update' => 'পৃষ্ঠা হালনাগাদ করেছেন', + 'page_update_notification' => 'পৃষ্ঠাটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'page_delete' => 'পৃষ্ঠা মুছে ফেলেছেন', + 'page_delete_notification' => 'পৃষ্ঠাটি সার্থকভাবে মুছে ফেলা হয়েছে', + 'page_restore' => 'মুছে ফেলা পৃষ্ঠা পুনরুদ্ধার করেছেন', + 'page_restore_notification' => 'পৃষ্ঠাটি সার্থকভাবে পুনরুদ্ধার করা হয়েছে', + 'page_move' => 'পৃষ্ঠা স্থানান্তর করেছেন', + 'page_move_notification' => 'পৃষ্ঠাটি সার্থকভাবে স্থানান্তর করা হয়েছে', + + // Chapters + 'chapter_create' => 'নতুন অধ্যায় সৃষ্টি করেছেন', + 'chapter_create_notification' => 'অধ্যায়টি সার্থকভাবে তৈরী করা হয়েছে', + 'chapter_update' => 'অধ্যায় হালনাগাদ করেছেন', + 'chapter_update_notification' => 'অধ্যায়টি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'chapter_delete' => 'অধ্যায় মুছে ফেলেছেন', + 'chapter_delete_notification' => 'অধ্যায়টি সার্থকভাবে মুছে ফেলা হয়েছে', + 'chapter_move' => 'অধ্যায় স্থানান্তর করেছেন', + 'chapter_move_notification' => 'অধ্যায়টি সার্থকভাবে স্থানান্তর করা হয়েছে', + + // Books + 'book_create' => 'নতুন বই সৃষ্টি করেছেন', + 'book_create_notification' => 'বইটি সার্থকভাবে তৈরী করা হয়েছে', + 'book_create_from_chapter' => 'অধ্যায়কে বইতে রূপান্তরিত করেছেন', + 'book_create_from_chapter_notification' => 'অধ্যায়কে বইতে রূপান্তর করার প্রক্রিয়া সফলভাবে সম্পন্ন হয়েছে', + 'book_update' => 'বই হালনাগাদ করেছেন', + 'book_update_notification' => 'বইটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'book_delete' => 'বই মুছে ফেলেছেন', + 'book_delete_notification' => 'বইটি সার্থকভাবে মুছে ফেলা হয়েছে', + 'book_sort' => 'বইটি ক্রমানুযায়ী সাজিয়েছেন', + 'book_sort_notification' => 'বইটি সার্থকভাবে ক্রমানুযায়ী সাজানো হয়েছে', + + // Bookshelves + 'bookshelf_create' => 'নতুন বুকশেলফ তৈরী করেছেন', + 'bookshelf_create_notification' => 'বুকশেলফটি সার্থকভাবে তৈরী করা হয়েছে', + 'bookshelf_create_from_book' => 'বইটিকে বুকশেলফে রূপান্তরিত করার প্রক্রিয়া সফলভাবে সম্পন্ন হয়েছে', + 'bookshelf_create_from_book_notification' => 'বইকে বুকশেলফে রূপান্তর করার প্রক্রিয়া সফলভাবে সম্পন্ন হয়েছে', + 'bookshelf_update' => 'বুকশেলফটি হালনাগাদ করেছেন', + 'bookshelf_update_notification' => 'বুকশেলফটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'bookshelf_delete' => 'বুকশেলফটি মুছে ফেলেছেন', + 'bookshelf_delete_notification' => 'বুকশেলফটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Revisions + 'revision_restore' => 'সংশোধনী পুনঃস্থাপন করেছেন', + 'revision_delete' => 'সংশোধনী মুছে ফেলেছেন', + 'revision_delete_notification' => 'সংশোধনী সার্থকভাবে মুছে ফেলা হয়েছে', + + // Favourites + 'favourite_add_notification' => 'আপনার প্রিয় তালিকায় ":name" যোগ করা হয়েছে', + 'favourite_remove_notification' => 'আপনার প্রিয় তালিকা হতে ":name"-কে মুছে ফেলা হয়েছে', + + // Watching + 'watch_update_level_notification' => 'পর্যবেক্ষণনীতি সার্থকভাবে হালনাগাদ করা হয়েছে', + + // Auth + 'auth_login' => 'লগড ইন অবস্থায় আছেন', + 'auth_register' => 'নতুন ব্যবহারকারী হিসাবে নিবন্ধিত', + 'auth_password_reset_request' => 'ব্যবহারকারীর পাসওয়ার্ড রিসেটের আবেদন করেছেন', + 'auth_password_reset_update' => 'ব্যবহারকারী পাসওয়ার্ড রিসেট করুন', + 'mfa_setup_method' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন সক্রিয় করেছেন', + 'mfa_setup_method_notification' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন সার্থকভাবে সক্রিয় করা হয়েছে', + 'mfa_remove_method' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন নিষ্ক্রিয় করেছেন', + 'mfa_remove_method_notification' => 'মাল্টি ফ্যাক্টর অথেনটিকেশন সার্থকভাবে নিষ্ক্রিয় করা হয়েছে', + + // Settings + 'settings_update' => 'সেটিংস হালনাগাদ করেছেন', + 'settings_update_notification' => 'সেটিংস সার্থকভাবে হালনাগাদ করা হয়েছে', + 'maintenance_action_run' => 'রক্ষণাবেক্ষণ কার্যক্রম চালু করেছেন', + + // Webhooks + 'webhook_create' => 'নতুন ওয়েবহুক তৈরী করেছেন', + 'webhook_create_notification' => 'নতুন ওয়েবহুক সার্থকভাবে তৈরী করা হয়েছে', + 'webhook_update' => 'ওয়েবহুকটি হালনাগাদ করেছেন', + 'webhook_update_notification' => 'ওয়েবহুকটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'webhook_delete' => 'ওয়েবহুকটি মুছে ফেলেছেন', + 'webhook_delete_notification' => 'ওয়েবহুকটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Imports + 'import_create' => 'ইমপোর্টটি তৈরী করেছেন', + 'import_create_notification' => 'ইমপোর্টটি সার্থকভাবে আপলোড করা হয়েছে', + 'import_run' => 'ইমপোর্টটি হালনাগাদ করেছেন', + 'import_run_notification' => 'কনটেন্ট সার্থকভাবে ইমপোর্ট করা হয়েছে', + 'import_delete' => 'ইমপোর্টটি মুছে ফেলেছেন', + 'import_delete_notification' => 'ইমপোর্টটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Users + 'user_create' => 'নতুন ব্যবহারকারী তৈরী করেছেন', + 'user_create_notification' => 'নতুন ব্যবহারকারী সার্থকভাবে তৈরী করা হয়েছে', + 'user_update' => 'ব্যবহারকারীটি হালনাগাদ করেছেন', + 'user_update_notification' => 'ব্যবহারকারীটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'user_delete' => 'ব্যবহারকারীটি মুছে ফেলেছেন', + 'user_delete_notification' => 'ব্যবহারকারীটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // API Tokens + 'api_token_create' => 'এপিআই টোকেনটি তৈরী করেছেন', + 'api_token_create_notification' => 'এপিআই টোকেনটি সার্থকভাবে তৈরী করা হয়েছে', + 'api_token_update' => 'এপিআই টোকেনটি হালনাগাদ করেছেন', + 'api_token_update_notification' => 'এপিআই টোকেনটি হালনাগাদ করা হয়েছে', + 'api_token_delete' => 'এপিআই টোকেনটি মুছে ফেলেছেন', + 'api_token_delete_notification' => 'এপিআই টোকেনটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Roles + 'role_create' => 'রোলটি তৈরী করেছেন', + 'role_create_notification' => 'রোলটি সার্থকভাবে তৈরী করা হয়েছে', + 'role_update' => 'রোলটি হালনাগাদ করেছেন', + 'role_update_notification' => 'রোলটি সার্থকভাবে হালনাগাদ করা হয়েছে', + 'role_delete' => 'রোলটি মুছে ফেলেছেন', + 'role_delete_notification' => 'রোলটি সার্থকভাবে মুছে ফেলা হয়েছে', + + // Recycle Bin + 'recycle_bin_empty' => 'রিসাইকেল বিন খালি করে ফেলেছেন', + 'recycle_bin_restore' => 'রিসাইকেল বিন হতে প্রত্যাবর্তন করা হয়েছে', + 'recycle_bin_destroy' => 'রিসাইকেল বিন হতে অপসারণ করা হয়েছে', + + // Comments + 'commented_on' => 'মন্তব্য প্রদান করেছেন', + 'comment_create' => 'মন্তব্য যোগ করেছেন', + 'comment_update' => 'মন্তব্য হালনাগাদ করেছেন', + 'comment_delete' => 'মন্তব্য মুছে ফেলেছেন', + + // Other + 'permissions_update' => 'অনুমতিক্রম হালনাগাদ করেছেন', +]; diff --git a/lang/bn/auth.php b/lang/bn/auth.php new file mode 100644 index 000000000..aca585962 --- /dev/null +++ b/lang/bn/auth.php @@ -0,0 +1,117 @@ + 'প্রদত্ত তথ্যনিরূপিত কোন রেকর্ড পাওয়া যায়নি।', + 'throttle' => 'লগইন প্রচেষ্টার সীমা অতিক্রান্ত। দয়া করে :seconds সেকেন্ড পর আবার চেষ্টা করুন।', + + // Login & Register + 'sign_up' => 'নিবন্ধিত হোন', + 'log_in' => 'লগ ইন করুন', + 'log_in_with' => ':socialDriver দ্বারা লগইন করুন', + 'sign_up_with' => ':socialDriver দ্বারা নিবন্ধিত হোন', + 'logout' => 'Logout', + + 'name' => 'Name', + 'username' => 'Username', + 'email' => 'Email', + 'password' => 'Password', + 'password_confirm' => 'Confirm Password', + 'password_hint' => 'Must be at least 8 characters', + 'forgot_password' => 'Forgot Password?', + 'remember_me' => 'Remember Me', + 'ldap_email_hint' => 'Please enter an email to use for this account.', + 'create_account' => 'Create Account', + 'already_have_account' => 'Already have an account?', + 'dont_have_account' => 'Don\'t have an account?', + 'social_login' => 'Social Login', + 'social_registration' => 'Social Registration', + 'social_registration_text' => 'Register and sign in using another service.', + + 'register_thanks' => 'Thanks for registering!', + 'register_confirm' => 'Please check your email and click the confirmation button to access :appName.', + 'registrations_disabled' => 'Registrations are currently disabled', + 'registration_email_domain_invalid' => 'That email domain does not have access to this application', + 'register_success' => 'Thanks for signing up! You are now registered and signed in.', + + // Login auto-initiation + 'auto_init_starting' => 'Attempting Login', + 'auto_init_starting_desc' => 'We\'re contacting your authentication system to start the login process. If there\'s no progress after 5 seconds you can try clicking the link below.', + 'auto_init_start_link' => 'Proceed with authentication', + + // Password Reset + 'reset_password' => 'Reset Password', + 'reset_password_send_instructions' => 'Enter your email below and you will be sent an email with a password reset link.', + 'reset_password_send_button' => 'Send Reset Link', + 'reset_password_sent' => 'A password reset link will be sent to :email if that email address is found in the system.', + 'reset_password_success' => 'Your password has been successfully reset.', + 'email_reset_subject' => 'Reset your :appName password', + 'email_reset_text' => 'You are receiving this email because we received a password reset request for your account.', + 'email_reset_not_requested' => 'If you did not request a password reset, no further action is required.', + + // Email Confirmation + 'email_confirm_subject' => 'Confirm your email on :appName', + 'email_confirm_greeting' => 'Thanks for joining :appName!', + 'email_confirm_text' => 'Please confirm your email address by clicking the button below:', + 'email_confirm_action' => 'Confirm Email', + 'email_confirm_send_error' => 'Email confirmation required but the system could not send the email. Contact the admin to ensure email is set up correctly.', + 'email_confirm_success' => 'Your email has been confirmed! You should now be able to login using this email address.', + 'email_confirm_resent' => 'Confirmation email resent, Please check your inbox.', + 'email_confirm_thanks' => 'Thanks for confirming!', + 'email_confirm_thanks_desc' => 'Please wait a moment while your confirmation is handled. If you are not redirected after 3 seconds press the "Continue" link below to proceed.', + + 'email_not_confirmed' => 'Email Address Not Confirmed', + 'email_not_confirmed_text' => 'Your email address has not yet been confirmed.', + 'email_not_confirmed_click_link' => 'Please click the link in the email that was sent shortly after you registered.', + 'email_not_confirmed_resend' => 'If you cannot find the email you can re-send the confirmation email by submitting the form below.', + 'email_not_confirmed_resend_button' => 'Resend Confirmation Email', + + // User Invite + 'user_invite_email_subject' => 'You have been invited to join :appName!', + 'user_invite_email_greeting' => 'An account has been created for you on :appName.', + 'user_invite_email_text' => 'Click the button below to set an account password and gain access:', + 'user_invite_email_action' => 'Set Account Password', + 'user_invite_page_welcome' => 'Welcome to :appName!', + 'user_invite_page_text' => 'To finalise your account and gain access you need to set a password which will be used to log-in to :appName on future visits.', + 'user_invite_page_confirm_button' => 'Confirm Password', + 'user_invite_success_login' => 'Password set, you should now be able to login using your set password to access :appName!', + + // Multi-factor Authentication + 'mfa_setup' => 'Setup Multi-Factor Authentication', + 'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'mfa_setup_configured' => 'Already configured', + 'mfa_setup_reconfigure' => 'Reconfigure', + 'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?', + 'mfa_setup_action' => 'Setup', + 'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.', + 'mfa_option_totp_title' => 'Mobile App', + 'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Backup Codes', + 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_gen_confirm_and_enable' => 'Confirm and Enable', + 'mfa_gen_backup_codes_title' => 'Backup Codes Setup', + 'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.', + 'mfa_gen_backup_codes_download' => 'Download Codes', + 'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once', + 'mfa_gen_totp_title' => 'Mobile App Setup', + 'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.', + 'mfa_gen_totp_verify_setup' => 'Verify Setup', + 'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:', + 'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here', + 'mfa_verify_access' => 'Verify Access', + 'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.', + 'mfa_verify_no_methods' => 'No Methods Configured', + 'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.', + 'mfa_verify_use_totp' => 'Verify using a mobile app', + 'mfa_verify_use_backup_codes' => 'Verify using a backup code', + 'mfa_verify_backup_code' => 'Backup Code', + 'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:', + 'mfa_verify_backup_code_enter_here' => 'Enter backup code here', + 'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:', + 'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.', +]; diff --git a/lang/bn/common.php b/lang/bn/common.php new file mode 100644 index 000000000..8f425abee --- /dev/null +++ b/lang/bn/common.php @@ -0,0 +1,113 @@ + 'প্রত্যাহার করুন', + 'close' => 'বন্ধ করুন', + 'confirm' => 'নিশ্চিত করুন', + 'back' => 'প্রত্যাবর্তন করুন', + 'save' => 'সংরক্ষণ করুন', + 'continue' => 'অগ্রসর হউন', + 'select' => 'নির্বাচন করুন', + 'toggle_all' => 'সবগুলোকে টগল করুন', + 'more' => 'বিস্তারিত', + + // Form Labels + 'name' => 'নাম', + 'description' => 'বিবরণ', + 'role' => 'রোল', + 'cover_image' => 'প্রচ্ছদ ছবি', + 'cover_image_description' => 'এই চিত্রটি আনুমানিক 440x250px হওয়া বাঞ্চনীয়। ক্ষেত্রবিশেষে ও ব্যবহারকারীর ইন্টারফেসের সাথে মানানসই করে উপস্থাপন করার জন্যে প্রয়োজনে এর আকার পরিবর্তন করে প্রদর্শন করা হবে, যা প্রকৃত মাত্রা হতে ভিন্ন হবে৷', + + // Actions + 'actions' => 'কার্যক্রম', + 'view' => 'দেখুন', + 'view_all' => 'সব দেখুন', + 'new' => 'নতুন', + 'create' => 'তৈরী করুন', + 'update' => 'হালনাগাদ করুন', + 'edit' => 'সম্পাদন করুন', + 'sort' => 'ক্রমান্বয় করুন', + 'move' => 'স্থানান্তর করুন', + 'copy' => 'অনুলিপি করুন', + 'reply' => 'প্রত্যুত্তর করুন', + 'delete' => 'মুছে ফেলুন', + 'delete_confirm' => 'মুছে ফেলা নিশ্চিত করুন', + 'search' => 'অনুসন্ধান করুন', + 'search_clear' => 'অনুসন্ধান পুনঃসূচনা করুন', + 'reset' => 'পুনঃসূচনা করুন', + 'remove' => 'অপসারণ করুন', + 'add' => 'যোগ করুন', + 'configure' => 'সংস্থাপন করুন', + 'manage' => 'ব্যবস্থাপনা করুন', + 'fullscreen' => 'ফুলস্ক্রিন', + 'favourite' => 'প্রিয় তালিকায় যুক্ত করুন', + 'unfavourite' => 'প্রিয় তালিকা হতে অপসারণ করুন', + 'next' => 'পরবর্তী', + 'previous' => 'পূর্ববর্তী', + 'filter_active' => 'Active Filter:', + 'filter_clear' => 'Clear Filter', + 'download' => 'Download', + 'open_in_tab' => 'Open in Tab', + 'open' => 'Open', + + // Sort Options + 'sort_options' => 'Sort Options', + 'sort_direction_toggle' => 'Sort Direction Toggle', + 'sort_ascending' => 'Sort Ascending', + 'sort_descending' => 'Sort Descending', + 'sort_name' => 'Name', + 'sort_default' => 'Default', + 'sort_created_at' => 'Created Date', + 'sort_updated_at' => 'Updated Date', + + // Misc + 'deleted_user' => 'Deleted User', + 'no_activity' => 'No activity to show', + 'no_items' => 'No items available', + 'back_to_top' => 'Back to top', + 'skip_to_main_content' => 'Skip to main content', + 'toggle_details' => 'Toggle Details', + 'toggle_thumbnails' => 'Toggle Thumbnails', + 'details' => 'Details', + 'grid_view' => 'Grid View', + 'list_view' => 'List View', + 'default' => 'Default', + 'breadcrumb' => 'Breadcrumb', + 'status' => 'অবস্থা', + 'status_active' => 'Active', + 'status_inactive' => 'নিষ্ক্রিয়', + 'never' => 'অভূতপূর্ব', + 'none' => 'None', + + // Header + 'homepage' => 'নীড়পাতা', + 'header_menu_expand' => 'হেডার মেন্যু প্রসারিত করুন', + 'profile_menu' => 'প্রোফাইল মেন্যু', + 'view_profile' => 'প্রোফাইল দেখুন', + 'edit_profile' => 'প্রোফাইল সম্পাদনা করুন', + 'dark_mode' => 'নৈশরূপ', + 'light_mode' => 'দিবারূপ', + 'global_search' => 'সকল স্থানে অনুসন্ধান', + + // Layout tabs + 'tab_info' => 'তথ্য', + 'tab_info_label' => 'ট্যাব: গৌণ তথ্য', + 'tab_content' => 'কনটেন্ট', + 'tab_content_label' => 'ট্যাব: মূখ্য তথ্য', + + // Email Content + 'email_action_help' => 'আপনার যদি ":actionText"-এ ক্লিক করতে সমস্যা হয়, তবে নিচের লিংকটি কপি করে আপনার ওয়েব ব্রাউজারে পেস্ট করুন:', + 'email_rights' => 'সর্বস্বত্ব সংরক্ষিত', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'গোপনীয়তা নীতি', + 'terms_of_service' => 'পরিষেবার শর্তাবলী', + + // OpenSearch + 'opensearch_description' => 'অনুসন্ধান :appName', +]; diff --git a/lang/bn/components.php b/lang/bn/components.php new file mode 100644 index 000000000..c33b1d0b7 --- /dev/null +++ b/lang/bn/components.php @@ -0,0 +1,46 @@ + 'Image Select', + 'image_list' => 'Image List', + 'image_details' => 'Image Details', + 'image_upload' => 'Upload Image', + 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', + 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_all' => 'All', + 'image_all_title' => 'View all images', + 'image_book_title' => 'View images uploaded to this book', + 'image_page_title' => 'View images uploaded to this page', + 'image_search_hint' => 'Search by image name', + 'image_uploaded' => 'Uploaded :uploadedDate', + 'image_uploaded_by' => 'Uploaded by :userName', + 'image_uploaded_to' => 'Uploaded to :pageLink', + 'image_updated' => 'Updated :updateDate', + 'image_load_more' => 'Load More', + 'image_image_name' => 'Image Name', + 'image_delete_used' => 'This image is used in the pages below.', + 'image_delete_confirm_text' => 'Are you sure you want to delete this image?', + 'image_select_image' => 'Select Image', + 'image_dropzone' => 'Drop images or click here to upload', + 'image_dropzone_drop' => 'Drop images here to upload', + 'images_deleted' => 'Images Deleted', + 'image_preview' => 'Image Preview', + 'image_upload_success' => 'Image uploaded successfully', + 'image_update_success' => 'Image details successfully updated', + 'image_delete_success' => 'Image successfully deleted', + 'image_replace' => 'Replace Image', + 'image_replace_success' => 'Image file successfully updated', + 'image_rebuild_thumbs' => 'Regenerate Size Variations', + 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + + // Code Editor + 'code_editor' => 'Edit Code', + 'code_language' => 'Code Language', + 'code_content' => 'Code Content', + 'code_session_history' => 'Session History', + 'code_save' => 'Save Code', +]; diff --git a/lang/bn/editor.php b/lang/bn/editor.php new file mode 100644 index 000000000..8bcef5f23 --- /dev/null +++ b/lang/bn/editor.php @@ -0,0 +1,179 @@ + 'সাধারণ', + 'advanced' => 'উন্নত', + 'none' => 'অপ্রযোজ্য', + 'cancel' => 'প্রত্যাহার করুন', + 'save' => 'সংরক্ষণ করুন', + 'close' => 'বন্ধ করুন', + 'undo' => 'প্রত্যাহার করুন', + 'redo' => 'পুনর্বহাল রাখুন', + 'left' => 'বাম', + 'center' => 'মধ্য', + 'right' => 'ডান', + 'top' => 'উপর', + 'middle' => 'মধ্য', + 'bottom' => 'নিচে', + 'width' => 'প্রস্থ', + 'height' => 'উচ্চতা', + 'More' => 'বিস্তারিত', + 'select' => 'নির্বাচন করুন...', + + // Toolbar + 'formats' => 'প্রকরণ', + 'header_large' => 'বড় হেডার', + 'header_medium' => 'মাঝারি হেডার', + 'header_small' => 'ছোট হেডার', + 'header_tiny' => 'ক্ষুদ্র হেডার', + 'paragraph' => 'প্যারাগ্রাফ', + 'blockquote' => 'ব্লককোট', + 'inline_code' => 'ইনলাইন কোড', + 'callouts' => 'কলআউট', + 'callout_information' => 'তথ্যমূলক', + 'callout_success' => 'সফলজনক', + 'callout_warning' => 'সতর্কতামূলক', + 'callout_danger' => 'বিপদজনক', + 'bold' => 'বোল্ড', + 'italic' => 'ইটালিক', + 'underline' => 'আন্ডারলাইন', + 'strikethrough' => 'স্ট্রাইকথ্রু', + 'superscript' => 'Superscript', + 'subscript' => 'Subscript', + 'text_color' => 'Text color', + 'custom_color' => 'Custom color', + 'remove_color' => 'Remove color', + 'background_color' => 'Background color', + 'align_left' => 'Align left', + 'align_center' => 'Align center', + 'align_right' => 'Align right', + 'align_justify' => 'Justify', + 'list_bullet' => 'Bullet list', + 'list_numbered' => 'Numbered list', + 'list_task' => 'Task list', + 'indent_increase' => 'Increase indent', + 'indent_decrease' => 'Decrease indent', + 'table' => 'Table', + 'insert_image' => 'Insert image', + 'insert_image_title' => 'Insert/Edit Image', + 'insert_link' => 'Insert/edit link', + 'insert_link_title' => 'Insert/Edit Link', + 'insert_horizontal_line' => 'Insert horizontal line', + 'insert_code_block' => 'Insert code block', + 'edit_code_block' => 'Edit code block', + 'insert_drawing' => 'Insert/edit drawing', + 'drawing_manager' => 'Drawing manager', + 'insert_media' => 'Insert/edit media', + 'insert_media_title' => 'Insert/Edit Media', + 'clear_formatting' => 'Clear formatting', + 'source_code' => 'Source code', + 'source_code_title' => 'Source Code', + 'fullscreen' => 'Fullscreen', + 'image_options' => 'Image options', + + // Tables + 'table_properties' => 'Table properties', + 'table_properties_title' => 'Table Properties', + 'delete_table' => 'Delete table', + 'table_clear_formatting' => 'Clear table formatting', + 'resize_to_contents' => 'Resize to contents', + 'row_header' => 'Row header', + 'insert_row_before' => 'Insert row before', + 'insert_row_after' => 'Insert row after', + 'delete_row' => 'Delete row', + 'insert_column_before' => 'Insert column before', + 'insert_column_after' => 'Insert column after', + 'delete_column' => 'Delete column', + 'table_cell' => 'Cell', + 'table_row' => 'Row', + 'table_column' => 'Column', + 'cell_properties' => 'Cell properties', + 'cell_properties_title' => 'Cell Properties', + 'cell_type' => 'Cell type', + 'cell_type_cell' => 'Cell', + 'cell_scope' => 'Scope', + 'cell_type_header' => 'Header cell', + 'merge_cells' => 'Merge cells', + 'split_cell' => 'Split cell', + 'table_row_group' => 'Row Group', + 'table_column_group' => 'Column Group', + 'horizontal_align' => 'Horizontal align', + 'vertical_align' => 'Vertical align', + 'border_width' => 'Border width', + 'border_style' => 'Border style', + 'border_color' => 'Border color', + 'row_properties' => 'Row properties', + 'row_properties_title' => 'Row Properties', + 'cut_row' => 'Cut row', + 'copy_row' => 'Copy row', + 'paste_row_before' => 'Paste row before', + 'paste_row_after' => 'Paste row after', + 'row_type' => 'Row type', + 'row_type_header' => 'Header', + 'row_type_body' => 'Body', + 'row_type_footer' => 'Footer', + 'alignment' => 'Alignment', + 'cut_column' => 'Cut column', + 'copy_column' => 'Copy column', + 'paste_column_before' => 'Paste column before', + 'paste_column_after' => 'Paste column after', + 'cell_padding' => 'Cell padding', + 'cell_spacing' => 'Cell spacing', + 'caption' => 'Caption', + 'show_caption' => 'Show caption', + 'constrain' => 'Constrain proportions', + 'cell_border_solid' => 'Solid', + 'cell_border_dotted' => 'Dotted', + 'cell_border_dashed' => 'Dashed', + 'cell_border_double' => 'Double', + 'cell_border_groove' => 'Groove', + 'cell_border_ridge' => 'Ridge', + 'cell_border_inset' => 'Inset', + 'cell_border_outset' => 'Outset', + 'cell_border_none' => 'None', + 'cell_border_hidden' => 'Hidden', + + // Images, links, details/summary & embed + 'source' => 'Source', + 'alt_desc' => 'Alternative description', + 'embed' => 'Embed', + 'paste_embed' => 'Paste your embed code below:', + 'url' => 'URL', + 'text_to_display' => 'Text to display', + 'title' => 'Title', + 'open_link' => 'Open link', + 'open_link_in' => 'Open link in...', + 'open_link_current' => 'Current window', + 'open_link_new' => 'New window', + 'remove_link' => 'Remove link', + 'insert_collapsible' => 'Insert collapsible block', + 'collapsible_unwrap' => 'Unwrap', + 'edit_label' => 'Edit label', + 'toggle_open_closed' => 'Toggle open/closed', + 'collapsible_edit' => 'Edit collapsible block', + 'toggle_label' => 'Toggle label', + + // About view + 'about' => 'About the editor', + 'about_title' => 'About the WYSIWYG Editor', + 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', + 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', + 'save_continue' => 'Save Page & Continue', + 'callouts_cycle' => '(Keep pressing to toggle through types)', + 'link_selector' => 'Link to content', + 'shortcuts' => 'Shortcuts', + 'shortcut' => 'Shortcut', + 'shortcuts_intro' => 'The following shortcuts are available in the editor:', + 'windows_linux' => '(Windows/Linux)', + 'mac' => '(Mac)', + 'description' => 'Description', +]; diff --git a/lang/bn/entities.php b/lang/bn/entities.php new file mode 100644 index 000000000..26a563a7e --- /dev/null +++ b/lang/bn/entities.php @@ -0,0 +1,460 @@ + 'Recently Created', + 'recently_created_pages' => 'Recently Created Pages', + 'recently_updated_pages' => 'Recently Updated Pages', + 'recently_created_chapters' => 'Recently Created Chapters', + 'recently_created_books' => 'Recently Created Books', + 'recently_created_shelves' => 'Recently Created Shelves', + 'recently_update' => 'Recently Updated', + 'recently_viewed' => 'Recently Viewed', + 'recent_activity' => 'Recent Activity', + 'create_now' => 'Create one now', + 'revisions' => 'Revisions', + 'meta_revision' => 'Revision #:revisionCount', + 'meta_created' => 'Created :timeLength', + 'meta_created_name' => 'Created :timeLength by :user', + 'meta_updated' => 'Updated :timeLength', + 'meta_updated_name' => 'Updated :timeLength by :user', + 'meta_owned_name' => 'Owned by :user', + 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items', + 'entity_select' => 'Entity Select', + 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', + 'images' => 'Images', + 'my_recent_drafts' => 'My Recent Drafts', + 'my_recently_viewed' => 'My Recently Viewed', + 'my_most_viewed_favourites' => 'My Most Viewed Favourites', + 'my_favourites' => 'My Favourites', + 'no_pages_viewed' => 'You have not viewed any pages', + 'no_pages_recently_created' => 'No pages have been recently created', + 'no_pages_recently_updated' => 'No pages have been recently updated', + 'export' => 'Export', + 'export_html' => 'Contained Web File', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', + + // Permissions and restrictions + 'permissions' => 'Permissions', + 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', + 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', + 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', + 'permissions_save' => 'Save Permissions', + 'permissions_owner' => 'Owner', + 'permissions_role_everyone_else' => 'Everyone Else', + 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', + 'permissions_role_override' => 'Override permissions for role', + 'permissions_inherit_defaults' => 'Inherit defaults', + + // Search + 'search_results' => 'Search Results', + 'search_total_results_found' => ':count result found|:count total results found', + 'search_clear' => 'Clear Search', + 'search_no_pages' => 'No pages matched this search', + 'search_for_term' => 'Search for :term', + 'search_more' => 'More Results', + 'search_advanced' => 'Advanced Search', + 'search_terms' => 'Search Terms', + 'search_content_type' => 'Content Type', + 'search_exact_matches' => 'Exact Matches', + 'search_tags' => 'Tag Searches', + 'search_options' => 'Options', + 'search_viewed_by_me' => 'Viewed by me', + 'search_not_viewed_by_me' => 'Not viewed by me', + 'search_permissions_set' => 'Permissions set', + 'search_created_by_me' => 'Created by me', + 'search_updated_by_me' => 'Updated by me', + 'search_owned_by_me' => 'Owned by me', + 'search_date_options' => 'Date Options', + 'search_updated_before' => 'Updated before', + 'search_updated_after' => 'Updated after', + 'search_created_before' => 'Created before', + 'search_created_after' => 'Created after', + 'search_set_date' => 'Set Date', + 'search_update' => 'Update Search', + + // Shelves + 'shelf' => 'Shelf', + 'shelves' => 'Shelves', + 'x_shelves' => ':count Shelf|:count Shelves', + 'shelves_empty' => 'No shelves have been created', + 'shelves_create' => 'Create New Shelf', + 'shelves_popular' => 'Popular Shelves', + 'shelves_new' => 'New Shelves', + 'shelves_new_action' => 'New Shelf', + 'shelves_popular_empty' => 'The most popular shelves will appear here.', + 'shelves_new_empty' => 'The most recently created shelves will appear here.', + 'shelves_save' => 'Save Shelf', + 'shelves_books' => 'Books on this shelf', + 'shelves_add_books' => 'Add books to this shelf', + 'shelves_drag_books' => 'Drag books below to add them to this shelf', + 'shelves_empty_contents' => 'This shelf has no books assigned to it', + 'shelves_edit_and_assign' => 'Edit shelf to assign books', + 'shelves_edit_named' => 'Edit Shelf :name', + 'shelves_edit' => 'Edit Shelf', + 'shelves_delete' => 'Delete Shelf', + 'shelves_delete_named' => 'Delete Shelf :name', + 'shelves_delete_explain' => "This will delete the shelf with the name ':name'. Contained books will not be deleted.", + 'shelves_delete_confirmation' => 'Are you sure you want to delete this shelf?', + 'shelves_permissions' => 'Shelf Permissions', + 'shelves_permissions_updated' => 'Shelf Permissions Updated', + 'shelves_permissions_active' => 'Shelf Permissions Active', + 'shelves_permissions_cascade_warning' => 'Permissions on shelves do not automatically cascade to contained books. This is because a book can exist on multiple shelves. Permissions can however be copied down to child books using the option found below.', + 'shelves_permissions_create' => 'Shelf create permissions are only used for copying permissions to child books using the action below. They do not control the ability to create books.', + 'shelves_copy_permissions_to_books' => 'Copy Permissions to Books', + 'shelves_copy_permissions' => 'Copy Permissions', + 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', + 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + + // Books + 'book' => 'Book', + 'books' => 'Books', + 'x_books' => ':count Book|:count Books', + 'books_empty' => 'No books have been created', + 'books_popular' => 'Popular Books', + 'books_recent' => 'Recent Books', + 'books_new' => 'New Books', + 'books_new_action' => 'New Book', + 'books_popular_empty' => 'The most popular books will appear here.', + 'books_new_empty' => 'The most recently created books will appear here.', + 'books_create' => 'Create New Book', + 'books_delete' => 'Delete Book', + 'books_delete_named' => 'Delete Book :bookName', + 'books_delete_explain' => 'This will delete the book with the name \':bookName\'. All pages and chapters will be removed.', + 'books_delete_confirmation' => 'Are you sure you want to delete this book?', + 'books_edit' => 'Edit Book', + 'books_edit_named' => 'Edit Book :bookName', + 'books_form_book_name' => 'Book Name', + 'books_save' => 'Save Book', + 'books_permissions' => 'Book Permissions', + 'books_permissions_updated' => 'Book Permissions Updated', + 'books_empty_contents' => 'No pages or chapters have been created for this book.', + 'books_empty_create_page' => 'Create a new page', + 'books_empty_sort_current_book' => 'Sort the current book', + 'books_empty_add_chapter' => 'Add a chapter', + 'books_permissions_active' => 'Book Permissions Active', + 'books_search_this' => 'Search this book', + 'books_navigation' => 'Book Navigation', + 'books_sort' => 'Sort Book Contents', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_named' => 'Sort Book :bookName', + 'books_sort_name' => 'Sort by Name', + 'books_sort_created' => 'Sort by Created Date', + 'books_sort_updated' => 'Sort by Updated Date', + 'books_sort_chapters_first' => 'Chapters First', + 'books_sort_chapters_last' => 'Chapters Last', + 'books_sort_show_other' => 'Show Other Books', + 'books_sort_save' => 'Save New Order', + 'books_sort_show_other_desc' => 'Add other books here to include them in the sort operation, and allow easy cross-book reorganisation.', + 'books_sort_move_up' => 'Move Up', + 'books_sort_move_down' => 'Move Down', + 'books_sort_move_prev_book' => 'Move to Previous Book', + 'books_sort_move_next_book' => 'Move to Next Book', + 'books_sort_move_prev_chapter' => 'Move Into Previous Chapter', + 'books_sort_move_next_chapter' => 'Move Into Next Chapter', + 'books_sort_move_book_start' => 'Move to Start of Book', + 'books_sort_move_book_end' => 'Move to End of Book', + 'books_sort_move_before_chapter' => 'Move to Before Chapter', + 'books_sort_move_after_chapter' => 'Move to After Chapter', + 'books_copy' => 'Copy Book', + 'books_copy_success' => 'Book successfully copied', + + // Chapters + 'chapter' => 'Chapter', + 'chapters' => 'Chapters', + 'x_chapters' => ':count Chapter|:count Chapters', + 'chapters_popular' => 'Popular Chapters', + 'chapters_new' => 'New Chapter', + 'chapters_create' => 'Create New Chapter', + 'chapters_delete' => 'Delete Chapter', + 'chapters_delete_named' => 'Delete Chapter :chapterName', + 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.', + 'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?', + 'chapters_edit' => 'Edit Chapter', + 'chapters_edit_named' => 'Edit Chapter :chapterName', + 'chapters_save' => 'Save Chapter', + 'chapters_move' => 'Move Chapter', + 'chapters_move_named' => 'Move Chapter :chapterName', + 'chapters_copy' => 'Copy Chapter', + 'chapters_copy_success' => 'Chapter successfully copied', + 'chapters_permissions' => 'Chapter Permissions', + 'chapters_empty' => 'No pages are currently in this chapter.', + 'chapters_permissions_active' => 'Chapter Permissions Active', + 'chapters_permissions_success' => 'Chapter Permissions Updated', + 'chapters_search_this' => 'Search this chapter', + 'chapter_sort_book' => 'Sort Book', + + // Pages + 'page' => 'Page', + 'pages' => 'Pages', + 'x_pages' => ':count Page|:count Pages', + 'pages_popular' => 'Popular Pages', + 'pages_new' => 'New Page', + 'pages_attachments' => 'Attachments', + 'pages_navigation' => 'Page Navigation', + 'pages_delete' => 'Delete Page', + 'pages_delete_named' => 'Delete Page :pageName', + 'pages_delete_draft_named' => 'Delete Draft Page :pageName', + 'pages_delete_draft' => 'Delete Draft Page', + 'pages_delete_success' => 'Page deleted', + 'pages_delete_draft_success' => 'Draft page deleted', + 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_confirm' => 'Are you sure you want to delete this page?', + 'pages_delete_draft_confirm' => 'Are you sure you want to delete this draft page?', + 'pages_editing_named' => 'Editing Page :pageName', + 'pages_edit_draft_options' => 'Draft Options', + 'pages_edit_save_draft' => 'Save Draft', + 'pages_edit_draft' => 'Edit Page Draft', + 'pages_editing_draft' => 'Editing Draft', + 'pages_editing_page' => 'Editing Page', + 'pages_edit_draft_save_at' => 'Draft saved at ', + 'pages_edit_delete_draft' => 'Delete Draft', + 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.', + 'pages_edit_discard_draft' => 'Discard Draft', + 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', + 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', + 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', + 'pages_edit_switch_to_wysiwyg' => 'Switch to WYSIWYG Editor', + 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_set_changelog' => 'Set Changelog', + 'pages_edit_enter_changelog_desc' => 'Enter a brief description of the changes you\'ve made', + 'pages_edit_enter_changelog' => 'Enter Changelog', + 'pages_editor_switch_title' => 'Switch Editor', + 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', + 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', + 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', + 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', + 'pages_save' => 'Save Page', + 'pages_title' => 'Page Title', + 'pages_name' => 'Page Name', + 'pages_md_editor' => 'Editor', + 'pages_md_preview' => 'Preview', + 'pages_md_insert_image' => 'Insert Image', + 'pages_md_insert_link' => 'Insert Entity Link', + 'pages_md_insert_drawing' => 'Insert Drawing', + 'pages_md_show_preview' => 'Show preview', + 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_drawing_unsaved' => 'Unsaved Drawing Found', + 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', + 'pages_not_in_chapter' => 'Page is not in a chapter', + 'pages_move' => 'Move Page', + 'pages_copy' => 'Copy Page', + 'pages_copy_desination' => 'Copy Destination', + 'pages_copy_success' => 'Page successfully copied', + 'pages_permissions' => 'Page Permissions', + 'pages_permissions_success' => 'Page permissions updated', + 'pages_revision' => 'Revision', + 'pages_revisions' => 'Page Revisions', + 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', + 'pages_revisions_named' => 'Page Revisions for :pageName', + 'pages_revision_named' => 'Page Revision for :pageName', + 'pages_revision_restored_from' => 'Restored from #:id; :summary', + 'pages_revisions_created_by' => 'Created By', + 'pages_revisions_date' => 'Revision Date', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'Revision Number', + 'pages_revisions_numbered' => 'Revision #:id', + 'pages_revisions_numbered_changes' => 'Revision #:id Changes', + 'pages_revisions_editor' => 'Editor Type', + 'pages_revisions_changelog' => 'Changelog', + 'pages_revisions_changes' => 'Changes', + 'pages_revisions_current' => 'Current Version', + 'pages_revisions_preview' => 'Preview', + 'pages_revisions_restore' => 'Restore', + 'pages_revisions_none' => 'This page has no revisions', + 'pages_copy_link' => 'Copy Link', + 'pages_edit_content_link' => 'Jump to section in editor', + 'pages_pointer_enter_mode' => 'Enter section select mode', + 'pages_pointer_label' => 'Page Section Options', + 'pages_pointer_permalink' => 'Page Section Permalink', + 'pages_pointer_include_tag' => 'Page Section Include Tag', + 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', + 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_permissions_active' => 'Page Permissions Active', + 'pages_initial_revision' => 'Initial publish', + 'pages_references_update_revision' => 'System auto-update of internal links', + 'pages_initial_name' => 'New Page', + 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', + 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', + 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count users have started editing this page', + 'start_b' => ':userName has started editing this page', + 'time_a' => 'since the page was last updated', + 'time_b' => 'in the last :minCount minutes', + 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + ], + 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', + 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', + 'pages_specific' => 'Specific Page', + 'pages_is_template' => 'Page Template', + + // Editor Sidebar + 'toggle_sidebar' => 'Toggle Sidebar', + 'page_tags' => 'Page Tags', + 'chapter_tags' => 'Chapter Tags', + 'book_tags' => 'Book Tags', + 'shelf_tags' => 'Shelf Tags', + 'tag' => 'Tag', + 'tags' => 'Tags', + 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', + 'tag_name' => 'Tag Name', + 'tag_value' => 'Tag Value (Optional)', + 'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.", + 'tags_add' => 'Add another tag', + 'tags_remove' => 'Remove this tag', + 'tags_usages' => 'Total tag usages', + 'tags_assigned_pages' => 'Assigned to Pages', + 'tags_assigned_chapters' => 'Assigned to Chapters', + 'tags_assigned_books' => 'Assigned to Books', + 'tags_assigned_shelves' => 'Assigned to Shelves', + 'tags_x_unique_values' => ':count unique values', + 'tags_all_values' => 'All values', + 'tags_view_tags' => 'View Tags', + 'tags_view_existing_tags' => 'View existing tags', + 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', + 'attachments' => 'Attachments', + 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', + 'attachments_explain_instant_save' => 'Changes here are saved instantly.', + 'attachments_upload' => 'Upload File', + 'attachments_link' => 'Attach Link', + 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', + 'attachments_set_link' => 'Set Link', + 'attachments_delete' => 'Are you sure you want to delete this attachment?', + 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_no_files' => 'No files have been uploaded', + 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', + 'attachments_link_name' => 'Link Name', + 'attachment_link' => 'Attachment link', + 'attachments_link_url' => 'Link to file', + 'attachments_link_url_hint' => 'Url of site or file', + 'attach' => 'Attach', + 'attachments_insert_link' => 'Add Attachment Link to Page', + 'attachments_edit_file' => 'Edit File', + 'attachments_edit_file_name' => 'File Name', + 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', + 'attachments_order_updated' => 'Attachment order updated', + 'attachments_updated_success' => 'Attachment details updated', + 'attachments_deleted' => 'Attachment deleted', + 'attachments_file_uploaded' => 'File successfully uploaded', + 'attachments_file_updated' => 'File successfully updated', + 'attachments_link_attached' => 'Link successfully attached to page', + 'templates' => 'Templates', + 'templates_set_as_template' => 'Page is a template', + 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', + 'templates_replace_content' => 'Replace page content', + 'templates_append_content' => 'Append to page content', + 'templates_prepend_content' => 'Prepend to page content', + + // Profile View + 'profile_user_for_x' => 'User for :time', + 'profile_created_content' => 'Created Content', + 'profile_not_created_pages' => ':userName has not created any pages', + 'profile_not_created_chapters' => ':userName has not created any chapters', + 'profile_not_created_books' => ':userName has not created any books', + 'profile_not_created_shelves' => ':userName has not created any shelves', + + // Comments + 'comment' => 'Comment', + 'comments' => 'Comments', + 'comment_add' => 'Add Comment', + 'comment_placeholder' => 'Leave a comment here', + 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', + 'comment_save' => 'Save Comment', + 'comment_new' => 'New Comment', + 'comment_created' => 'commented :createDiff', + 'comment_updated' => 'Updated :updateDiff by :username', + 'comment_updated_indicator' => 'Updated', + 'comment_deleted_success' => 'Comment deleted', + 'comment_created_success' => 'Comment added', + 'comment_updated_success' => 'Comment updated', + 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', + 'comment_in_reply_to' => 'In reply to :commentId', + 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', + + // Revision + 'revision_delete_confirm' => 'Are you sure you want to delete this revision?', + 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', + 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + + // Conversions + 'convert_to_shelf' => 'Convert to Shelf', + 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', + 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', + 'convert_book' => 'Convert Book', + 'convert_book_confirm' => 'Are you sure you want to convert this book?', + 'convert_undo_warning' => 'This cannot be as easily undone.', + 'convert_to_book' => 'Convert to Book', + 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', + 'convert_chapter' => 'Convert Chapter', + 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + + // References + 'references' => 'References', + 'references_none' => 'There are no tracked references to this item.', + 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + + // Watch Options + 'watch' => 'Watch', + 'watch_title_default' => 'Default Preferences', + 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_title_ignore' => 'Ignore', + 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_title_new' => 'New Pages', + 'watch_desc_new' => 'Notify when any new page is created within this item.', + 'watch_title_updates' => 'All Page Updates', + 'watch_desc_updates' => 'Notify upon all new pages and page changes.', + 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', + 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', + 'watch_change_default' => 'Change default notification preferences', + 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_detail_new' => 'Watching for new pages', + 'watch_detail_updates' => 'Watching new pages and updates', + 'watch_detail_comments' => 'Watching new pages, updates & comments', + 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', + 'watch_detail_parent_chapter' => 'Watching via parent chapter', + 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', +]; diff --git a/lang/bn/errors.php b/lang/bn/errors.php new file mode 100644 index 000000000..ee2fbfa21 --- /dev/null +++ b/lang/bn/errors.php @@ -0,0 +1,133 @@ + 'অনুরোধকৃত পৃষ্ঠাটিতে আপনার ব্যবহারাধিকারের অনুমতি নেই।', + 'permissionJson' => 'You do not have permission to perform the requested action.', + + // Auth + 'error_user_exists_different_creds' => 'A user with the email :email already exists but with different credentials.', + 'auth_pre_register_theme_prevention' => 'User account could not be registered for the provided details', + 'email_already_confirmed' => 'Email has already been confirmed, Try logging in.', + 'email_confirmation_invalid' => 'This confirmation token is not valid or has already been used, Please try registering again.', + 'email_confirmation_expired' => 'The confirmation token has expired, A new confirmation email has been sent.', + 'email_confirmation_awaiting' => 'The email address for the account in use needs to be confirmed', + 'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind', + 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', + 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', + 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', + 'saml_already_logged_in' => 'Already logged in', + 'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.', + 'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'oidc_already_logged_in' => 'Already logged in', + 'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system', + 'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization', + 'social_no_action_defined' => 'No action defined', + 'social_login_bad_response' => "Error received during :socialAccount login: \n:error", + 'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.', + 'social_account_email_in_use' => 'The email :email is already in use. If you already have an account you can connect your :socialAccount account from your profile settings.', + 'social_account_existing' => 'This :socialAccount is already attached to your profile.', + 'social_account_already_used_existing' => 'This :socialAccount account is already used by another user.', + 'social_account_not_used' => 'This :socialAccount account is not linked to any users. Please attach it in your profile settings. ', + 'social_account_register_instructions' => 'If you do not yet have an account, You can register an account using the :socialAccount option.', + 'social_driver_not_found' => 'Social driver not found', + 'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.', + 'invite_token_expired' => 'This invitation link has expired. You can instead try to reset your account password.', + 'login_user_not_found' => 'A user for this action could not be found.', + + // System + 'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.', + 'cannot_get_image_from_url' => 'Cannot get image from :url', + 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', + 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', + 'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.', + 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', + + // Drawing & Images + 'image_upload_error' => 'An error occurred uploading the image', + 'image_upload_type_error' => 'The image type being uploaded is invalid', + 'image_upload_replace_type' => 'Image file replacements must be of the same type', + 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.', + 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.', + 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.', + 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', + + // Attachments + 'attachment_not_found' => 'Attachment not found', + 'attachment_upload_error' => 'An error occurred uploading the attachment file', + + // Pages + 'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', + 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', + 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage', + + // Entities + 'entity_not_found' => 'Entity not found', + 'bookshelf_not_found' => 'Shelf not found', + 'book_not_found' => 'Book not found', + 'page_not_found' => 'Page not found', + 'chapter_not_found' => 'Chapter not found', + 'selected_book_not_found' => 'The selected book was not found', + 'selected_book_chapter_not_found' => 'The selected Book or Chapter was not found', + 'guests_cannot_save_drafts' => 'Guests cannot save drafts', + + // Users + 'users_cannot_delete_only_admin' => 'You cannot delete the only admin', + 'users_cannot_delete_guest' => 'You cannot delete the guest user', + 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + + // Roles + 'role_cannot_be_edited' => 'This role cannot be edited', + 'role_system_cannot_be_deleted' => 'This role is a system role and cannot be deleted', + 'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role', + 'role_cannot_remove_only_admin' => 'This user is the only user assigned to the administrator role. Assign the administrator role to another user before attempting to remove it here.', + + // Comments + 'comment_list' => 'An error occurred while fetching the comments.', + 'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.', + 'comment_add' => 'An error occurred while adding / updating the comment.', + 'comment_delete' => 'An error occurred while deleting the comment.', + 'empty_comment' => 'Cannot add an empty comment.', + + // Error pages + '404_page_not_found' => 'Page Not Found', + 'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.', + 'sorry_page_not_found_permission_warning' => 'If you expected this page to exist, you might not have permission to view it.', + 'image_not_found' => 'Image Not Found', + 'image_not_found_subtitle' => 'Sorry, The image file you were looking for could not be found.', + 'image_not_found_details' => 'If you expected this image to exist it might have been deleted.', + 'return_home' => 'Return to home', + 'error_occurred' => 'An Error Occurred', + '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.', + '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.', + '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', + 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', + 'api_user_token_not_found' => 'No matching API token was found for the provided authorization token', + 'api_incorrect_token_secret' => 'The secret provided for the given used API token is incorrect', + 'api_user_no_api_permission' => 'The owner of the used API token does not have permission to make API calls', + 'api_user_token_expired' => 'The authorization token used has expired', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Error thrown when sending a test email:', + + // HTTP errors + 'http_ssr_url_no_match' => 'The URL does not match the configured allowed SSR hosts', +]; diff --git a/lang/bn/notifications.php b/lang/bn/notifications.php new file mode 100644 index 000000000..1afd23f1d --- /dev/null +++ b/lang/bn/notifications.php @@ -0,0 +1,27 @@ + 'New comment on page: :pageName', + 'new_comment_intro' => 'A user has commented on a page in :appName:', + 'new_page_subject' => 'New page: :pageName', + 'new_page_intro' => 'A new page has been created in :appName:', + 'updated_page_subject' => 'Updated page: :pageName', + 'updated_page_intro' => 'A page has been updated in :appName:', + 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + + 'detail_page_name' => 'Page Name:', + 'detail_page_path' => 'Page Path:', + 'detail_commenter' => 'Commenter:', + 'detail_comment' => 'Comment:', + 'detail_created_by' => 'Created By:', + 'detail_updated_by' => 'Updated By:', + + 'action_view_comment' => 'View Comment', + 'action_view_page' => 'View Page', + + 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', + 'footer_reason_link' => 'your notification preferences', +]; diff --git a/lang/bn/pagination.php b/lang/bn/pagination.php new file mode 100644 index 000000000..2cf360a0f --- /dev/null +++ b/lang/bn/pagination.php @@ -0,0 +1,12 @@ + '« পূর্ববর্তী', + 'next' => 'পরবর্তী »', + +]; diff --git a/lang/bn/passwords.php b/lang/bn/passwords.php new file mode 100644 index 000000000..da318b1ac --- /dev/null +++ b/lang/bn/passwords.php @@ -0,0 +1,15 @@ + 'পাসওয়ার্ড কমপক্ষে আট অক্ষরের হতে হবে এবং পাসওয়ার্ড নিশ্চিতকরণের ঘরে প্রদত্ত পাসওয়ার্ডের সাথে মিলতে হবে।', + 'user' => "প্রদত্ত ইমেইল ঠিকানার স্বাপেক্ষে কোন ব্যবহারকারী খুঁজে পাওয়া যায়নি।", + 'token' => 'প্রদত্ত পাসওয়ার্ড রিসেট টোকেন অত্র ইমেল ঠিকানার জন্য বৈধ নয়৷', + 'sent' => 'আপনার পাসওয়ার্ড রিসেট লিঙ্কটি ই-মেইলের মাধ্যমে প্রেরণ করা হয়েছে!', + 'reset' => 'আপনার পাসওয়ার্ডটি রিসেট করা হয়েছে!', + +]; diff --git a/lang/bn/preferences.php b/lang/bn/preferences.php new file mode 100644 index 000000000..2e47604e4 --- /dev/null +++ b/lang/bn/preferences.php @@ -0,0 +1,51 @@ + 'আমার অ্যাকাউন্ট', + + 'shortcuts' => 'Shortcuts', + 'shortcuts_interface' => 'UI Shortcut Preferences', + 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', + 'shortcuts_customize_desc' => 'You can customize each of the shortcuts below. Just press your desired key combination after selecting the input for a shortcut.', + 'shortcuts_toggle_label' => 'Keyboard shortcuts enabled', + 'shortcuts_section_navigation' => 'Navigation', + 'shortcuts_section_actions' => 'Common Actions', + 'shortcuts_save' => 'Save Shortcuts', + 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', + 'shortcuts_update_success' => 'Shortcut preferences have been updated!', + 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + + 'notifications' => 'Notification Preferences', + 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', + 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', + 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', + 'notifications_save' => 'Save Preferences', + 'notifications_update_success' => 'Notification preferences have been updated!', + 'notifications_watched' => 'Watched & Ignored Items', + 'notifications_watched_desc' => 'Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + + 'auth' => 'Access & Security', + 'auth_change_password' => 'Change Password', + 'auth_change_password_desc' => 'Change the password you use to log-in to the application. This must be at least 8 characters long.', + 'auth_change_password_success' => 'Password has been updated!', + + 'profile' => 'Profile Details', + 'profile_desc' => 'Manage the details of your account which represents you to other users, in addition to details that are used for communication and system personalisation.', + 'profile_view_public' => 'View Public Profile', + 'profile_name_desc' => 'Configure your display name which will be visible to other users in the system through the activity you perform, and content you own.', + 'profile_email_desc' => 'This email will be used for notifications and, depending on active system authentication, system access.', + 'profile_email_no_permission' => 'Unfortunately you don\'t have permission to change your email address. If you want to change this, you\'d need to ask an administrator to change this for you.', + 'profile_avatar_desc' => 'Select an image which will be used to represent yourself to others in the system. Ideally this image should be square and about 256px in width and height.', + 'profile_admin_options' => 'Administrator Options', + 'profile_admin_options_desc' => 'Additional administrator-level options, like those to manage role assignments, can be found for your user account in the "Settings > Users" area of the application.', + + 'delete_account' => 'Delete Account', + 'delete_my_account' => 'Delete My Account', + 'delete_my_account_desc' => 'This will fully delete your user account from the system. You will not be able to recover this account or revert this action. Content you\'ve created, such as created pages and uploaded images, will remain.', + 'delete_my_account_warning' => 'Are you sure you want to delete your account?', +]; diff --git a/lang/bn/settings.php b/lang/bn/settings.php new file mode 100644 index 000000000..e66e32284 --- /dev/null +++ b/lang/bn/settings.php @@ -0,0 +1,339 @@ + 'সেটিংস', + 'settings_save' => 'সেটিংস সংরক্ষণ করুন', + 'system_version' => 'সিস্টেম ভার্সন', + 'categories' => 'শ্রেণীবিভাগ সমূহ', + + // App Settings + 'app_customization' => 'নিজস্বীকরণ', + 'app_features_security' => 'ফিচারসমূহ ও নিরাপত্তা', + 'app_name' => 'এপ্লিকেশনের নাম', + 'app_name_desc' => 'এই নামটি হেডারে এবং যেকোন সিস্টেম-প্রেরিত ইমেলে দেখানো হয়।', + 'app_name_header' => 'হেডারে নাম দেখান', + 'app_public_access' => 'পাবলিক এক্সেস', + 'app_public_access_desc' => 'উক্ত অপশনটি সক্রিয় করলে আপনার বুকস্ট্যাক ওয়েবসাইটের সকল তথ্য, যে কেউ লগ ইন করা ছাড়াই, দেখতে বা পড়তে অথবা এক্সেস করতে পারবেন।', + 'app_public_access_desc_guest' => '"Guest" ব্যবহারকারীর মাধ্যমে ওয়েবসাইট ভিসিটরদের পঠনধিকার নিয়ন্ত্রণ করা যেতে পারে।', + 'app_public_access_toggle' => 'পাবলিক অ্যাক্সেসের অনুমতি দিন', + 'app_public_viewing' => 'সকলের জন্যে উন্মুক্ত করতে চান?', + 'app_secure_images' => 'Higher Security Image Uploads', + 'app_secure_images_toggle' => 'Enable higher security image uploads', + 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', + 'app_default_editor' => 'Default Page Editor', + 'app_default_editor_desc' => 'Select which editor will be used by default when editing new pages. This can be overridden at a page level where permissions allow.', + 'app_custom_html' => 'Custom HTML Head Content', + 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', + 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', + 'app_logo' => 'অ্যাপ্লিকেশনের লোগো', + 'app_logo_desc' => 'এটি অ্যাপ্লিকেশনের হেডার বার-এ দেখানো হবে। উক্ত ছবিটির উচ্চতা সর্বোচ্চ ৮৬ পিক্সেলের হতে হবে। অধিকতর উচ্চতার ছবিকে স্কেল ডাউন করা হবে। ', + 'app_icon' => 'অ্যাপ্লিকেশনের আইকন', + 'app_icon_desc' => 'This icon is used for browser tabs and shortcut icons. This should be a 256px square PNG image.', + 'app_homepage' => 'অ্যাপ্লিকেশনের নীড়পাতা', + 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage_select' => 'একটি পৃষ্ঠা নির্বাচন করুন', + 'app_footer_links' => 'ফুটার লিঙ্কসমূহ', + 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', + 'app_footer_links_label' => 'Link Label', + 'app_footer_links_url' => 'Link URL', + 'app_footer_links_add' => 'Add Footer Link', + 'app_disable_comments' => 'Disable Comments', + 'app_disable_comments_toggle' => 'Disable comments', + 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
    Existing comments are not shown.', + + // Color settings + 'color_scheme' => 'Application Color Scheme', + 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', + 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', + 'app_color' => 'Primary Color', + 'link_color' => 'Default Link Color', + 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', + 'bookshelf_color' => 'Shelf Color', + 'book_color' => 'Book Color', + 'chapter_color' => 'Chapter Color', + 'page_color' => 'Page Color', + 'page_draft_color' => 'Page Draft Color', + + // Registration Settings + 'reg_settings' => 'Registration', + 'reg_enable' => 'Enable Registration', + 'reg_enable_toggle' => 'Enable registration', + 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', + 'reg_default_role' => 'Default user role after registration', + 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', + 'reg_email_confirmation' => 'Email Confirmation', + 'reg_email_confirmation_toggle' => 'Require email confirmation', + 'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.', + 'reg_confirm_restrict_domain' => 'Domain Restriction', + 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
    Note that users will be able to change their email addresses after successful registration.', + 'reg_confirm_restrict_domain_placeholder' => 'No restriction set', + + // Maintenance settings + 'maint' => 'Maintenance', + 'maint_image_cleanup' => 'Cleanup Images', + 'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.', + 'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions', + 'maint_image_cleanup_run' => 'Run Cleanup', + 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', + 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', + 'maint_image_cleanup_nothing_found' => 'No unused images found, Nothing deleted!', + 'maint_send_test_email' => 'Send a Test Email', + 'maint_send_test_email_desc' => 'This sends a test email to your email address specified in your profile.', + 'maint_send_test_email_run' => 'Send test email', + 'maint_send_test_email_success' => 'Email sent to :address', + 'maint_send_test_email_mail_subject' => 'Test Email', + 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', + 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', + 'maint_recycle_bin_open' => 'Open Recycle Bin', + 'maint_regen_references' => 'Regenerate References', + 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', + 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + + // Recycle Bin + 'recycle_bin' => 'Recycle Bin', + 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'recycle_bin_deleted_item' => 'Deleted Item', + 'recycle_bin_deleted_parent' => 'Parent', + 'recycle_bin_deleted_by' => 'Deleted By', + 'recycle_bin_deleted_at' => 'Deletion Time', + 'recycle_bin_permanently_delete' => 'Permanently Delete', + 'recycle_bin_restore' => 'Restore', + 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', + 'recycle_bin_empty' => 'Empty Recycle Bin', + 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', + 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_list' => 'Items to be Destroyed', + 'recycle_bin_restore_list' => 'Items to be Restored', + 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', + 'recycle_bin_restore_parent' => 'Restore Parent', + 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + + // Audit Log + 'audit' => 'Audit Log', + 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_event_filter' => 'Event Filter', + 'audit_event_filter_no_filter' => 'No Filter', + 'audit_deleted_item' => 'Deleted Item', + 'audit_deleted_item_name' => 'Name: :name', + 'audit_table_user' => 'User', + 'audit_table_event' => 'Event', + 'audit_table_related' => 'Related Item or Detail', + 'audit_table_ip' => 'IP Address', + 'audit_table_date' => 'Activity Date', + 'audit_date_from' => 'Date Range From', + 'audit_date_to' => 'Date Range To', + + // Role Settings + 'roles' => 'Roles', + 'role_user_roles' => 'User Roles', + 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', + 'roles_x_users_assigned' => ':count user assigned|:count users assigned', + 'roles_x_permissions_provided' => ':count permission|:count permissions', + 'roles_assigned_users' => 'Assigned Users', + 'roles_permissions_provided' => 'Provided Permissions', + 'role_create' => 'Create New Role', + 'role_delete' => 'Delete Role', + 'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.', + 'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', + 'role_delete_no_migration' => "Don't migrate users", + 'role_delete_sure' => 'Are you sure you want to delete this role?', + 'role_edit' => 'Edit Role', + 'role_details' => 'Role Details', + 'role_name' => 'Role Name', + 'role_desc' => 'Short Description of Role', + 'role_mfa_enforced' => 'Requires Multi-Factor Authentication', + 'role_external_auth_id' => 'External Authentication IDs', + 'role_system' => 'System Permissions', + 'role_manage_users' => 'Manage users', + 'role_manage_roles' => 'Manage roles & role permissions', + 'role_manage_entity_permissions' => 'Manage all book, chapter & page permissions', + 'role_manage_own_entity_permissions' => 'Manage permissions on own book, chapter & pages', + 'role_manage_page_templates' => 'Manage page templates', + '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', + 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', + 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', + 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', + 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_all' => 'All', + 'role_own' => 'Own', + 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_save' => 'Save Role', + 'role_users' => 'Users in this role', + 'role_users_none' => 'No users are currently assigned to this role', + + // Users + 'users' => 'Users', + 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', + 'user_profile' => 'User Profile', + 'users_add_new' => 'Add New User', + 'users_search' => 'Search Users', + 'users_latest_activity' => 'Latest Activity', + 'users_details' => 'User Details', + 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', + 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', + 'users_role' => 'User Roles', + 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', + 'users_password' => 'User Password', + 'users_password_desc' => 'Set a password used to log-in to the application. This must be at least 8 characters long.', + 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', + 'users_send_invite_option' => 'Send user invite email', + 'users_external_auth_id' => 'External Authentication ID', + 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', + 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', + 'users_delete' => 'Delete User', + 'users_delete_named' => 'Delete user :userName', + 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', + 'users_delete_confirm' => 'Are you sure you want to delete this user?', + 'users_migrate_ownership' => 'Migrate Ownership', + 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', + 'users_none_selected' => 'No user selected', + 'users_edit' => 'Edit User', + 'users_edit_profile' => 'Edit Profile', + 'users_avatar' => 'User Avatar', + 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', + 'users_preferred_language' => 'Preferred Language', + 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', + 'users_social_accounts' => 'Social Accounts', + 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Connect Account', + 'users_social_disconnect' => 'Disconnect Account', + 'users_social_status_connected' => 'Connected', + 'users_social_status_disconnected' => 'Disconnected', + 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', + 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', + 'users_api_tokens' => 'API Tokens', + 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_none' => 'No API tokens have been created for this user', + 'users_api_tokens_create' => 'Create Token', + 'users_api_tokens_expires' => 'Expires', + 'users_api_tokens_docs' => 'API Documentation', + 'users_mfa' => 'Multi-Factor Authentication', + 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_configure' => 'Configure Methods', + + // API Tokens + 'user_api_token_create' => 'Create API Token', + 'user_api_token_name' => 'Name', + 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', + 'user_api_token_expiry' => 'Expiry Date', + 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', + 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', + 'user_api_token' => 'API Token', + 'user_api_token_id' => 'Token ID', + 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', + 'user_api_token_secret' => 'Token Secret', + 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', + 'user_api_token_created' => 'Token created :timeAgo', + 'user_api_token_updated' => 'Token updated :timeAgo', + 'user_api_token_delete' => 'Delete Token', + 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', + 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', + 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_create' => 'Create New Webhook', + 'webhooks_none_created' => 'No webhooks have yet been created.', + 'webhooks_edit' => 'Edit Webhook', + 'webhooks_save' => 'Save Webhook', + 'webhooks_details' => 'Webhook Details', + 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', + 'webhooks_events' => 'Webhook Events', + 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', + 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_events_all' => 'All system events', + 'webhooks_name' => 'Webhook Name', + 'webhooks_timeout' => 'Webhook Request Timeout (Seconds)', + 'webhooks_endpoint' => 'Webhook Endpoint', + 'webhooks_active' => 'Webhook Active', + 'webhook_events_table_header' => 'Events', + 'webhooks_delete' => 'Delete Webhook', + 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', + 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', + 'webhooks_format_example' => 'Webhook Format Example', + 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + 'webhooks_status' => 'Webhook Status', + 'webhooks_last_called' => 'Last Called:', + 'webhooks_last_errored' => 'Last Errored:', + 'webhooks_last_error_message' => 'Last Error Message:', + + // Licensing + 'licenses' => 'Licenses', + 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', + 'licenses_bookstack' => 'BookStack License', + 'licenses_php' => 'PHP Library Licenses', + 'licenses_js' => 'JavaScript Library Licenses', + 'licenses_other' => 'Other Licenses', + 'license_details' => 'License Details', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'cy' => 'Cymraeg', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'el' => 'ελληνικά', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'et' => 'Eesti keel', + 'eu' => 'Euskara', + 'fa' => 'فارسی', + 'fi' => 'Suomi', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nb' => 'Norsk (Bokmål)', + 'nn' => 'Nynorsk', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ro' => 'Română', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'uz' => 'O‘zbekcha', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ], + //!//////////////////////////////// +]; diff --git a/lang/bn/validation.php b/lang/bn/validation.php new file mode 100644 index 000000000..d9b982d1e --- /dev/null +++ b/lang/bn/validation.php @@ -0,0 +1,122 @@ + 'The :attribute must be accepted.', + 'active_url' => 'The :attribute is not a valid URL.', + 'after' => 'The :attribute must be a date after :date.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'array' => 'The :attribute must be an array.', + 'backup_codes' => 'The provided code is not valid or has already been used.', + 'before' => 'The :attribute must be a date before :date.', + 'between' => [ + 'numeric' => 'The :attribute must be between :min and :max.', + 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'string' => 'The :attribute must be between :min and :max characters.', + 'array' => 'The :attribute must have between :min and :max items.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'email' => 'The :attribute must be a valid email address.', + 'ends_with' => 'The :attribute must end with one of the following: :values', + 'file' => 'The :attribute must be provided as a valid file.', + 'filled' => 'The :attribute field is required.', + 'gt' => [ + 'numeric' => 'The :attribute must be greater than :value.', + 'file' => 'The :attribute must be greater than :value kilobytes.', + 'string' => 'The :attribute must be greater than :value characters.', + 'array' => 'The :attribute must have more than :value items.', + ], + 'gte' => [ + 'numeric' => 'The :attribute must be greater than or equal :value.', + 'file' => 'The :attribute must be greater than or equal :value kilobytes.', + 'string' => 'The :attribute must be greater than or equal :value characters.', + 'array' => 'The :attribute must have :value items or more.', + ], + 'exists' => 'The selected :attribute is invalid.', + 'image' => 'The :attribute must be an image.', + 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'in' => 'The selected :attribute is invalid.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'ipv4' => 'The :attribute must be a valid IPv4 address.', + 'ipv6' => 'The :attribute must be a valid IPv6 address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'lt' => [ + 'numeric' => 'The :attribute must be less than :value.', + 'file' => 'The :attribute must be less than :value kilobytes.', + 'string' => 'The :attribute must be less than :value characters.', + 'array' => 'The :attribute must have less than :value items.', + ], + 'lte' => [ + 'numeric' => 'The :attribute must be less than or equal :value.', + 'file' => 'The :attribute must be less than or equal :value kilobytes.', + 'string' => 'The :attribute must be less than or equal :value characters.', + 'array' => 'The :attribute must not have more than :value items.', + ], + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute format is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values is present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'safe_url' => 'The provided link may not be safe.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'totp' => 'The provided code is not valid or has expired.', + 'unique' => 'The :attribute has already been taken.', + '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_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.', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Password confirmation required', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/lang/bs/activities.php b/lang/bs/activities.php index ee538c23a..fc63058ae 100644 --- a/lang/bs/activities.php +++ b/lang/bs/activities.php @@ -84,6 +84,14 @@ return [ '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/bs/editor.php b/lang/bs/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/bs/editor.php +++ b/lang/bs/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/bs/entities.php b/lang/bs/entities.php index 1b59ecac8..fc6e19ddd 100644 --- a/lang/bs/entities.php +++ b/lang/bs/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF fajl', 'export_text' => 'Plain Text fajl', '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Dozvole', diff --git a/lang/bs/errors.php b/lang/bs/errors.php index be633b589..f60f92f07 100644 --- a/lang/bs/errors.php +++ b/lang/bs/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName trenutno nije u funkciji', 'back_soon' => 'Biti će uskoro u funkciji.', + // 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.', + '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.', + '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' => 'Na zahtjevu nije pronađen token za autorizaciju', 'api_bad_authorization_format' => 'Token za autorizaciju je pronađen u zahtjevu ali je format neispravan', diff --git a/lang/bs/settings.php b/lang/bs/settings.php index 5427cb941..c0b6b692a 100644 --- a/lang/bs/settings.php +++ b/lang/bs/settings.php @@ -162,6 +162,7 @@ return [ '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/lang/bs/validation.php b/lang/bs/validation.php index b07df9178..4b026afd2 100644 --- a/lang/bs/validation.php +++ b/lang/bs/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Format :attribute je neispravan.', 'uploaded' => 'Fajl nije učitan. Server ne prihvata fajlove ove veličine.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ca/activities.php b/lang/ca/activities.php index c28838482..883567664 100644 --- a/lang/ca/activities.php +++ b/lang/ca/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'ha suprimit el webhook', 'webhook_delete_notification' => 'S’ha suprimit el webhook', + // 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' => 'ha creat l’usuari', 'user_create_notification' => 'S’ha creat l’usuari', diff --git a/lang/ca/editor.php b/lang/ca/editor.php index 7a09986a2..cdd80c245 100644 --- a/lang/ca/editor.php +++ b/lang/ca/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Quant a l’Editor', 'about_title' => 'Quant a l’Editor WYSIWYG', 'editor_license' => 'Llicència i copyright de l’Editor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Aquest editor s’ha creat amb :tinyLink que es proporciona amb la llicència MIT.', 'editor_tiny_license_link' => 'Detalls de la llicència i el copyright de TinyMCE.', 'save_continue' => 'Desa la pàgina i continua', diff --git a/lang/ca/entities.php b/lang/ca/entities.php index 0f241d9f2..ef2a4c2a8 100644 --- a/lang/ca/entities.php +++ b/lang/ca/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Fitxer PDF', 'export_text' => 'Fitxer de text sense format', 'export_md' => 'Fitxer Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/ca/errors.php b/lang/ca/errors.php index 03a07bb33..76fb293da 100644 --- a/lang/ca/errors.php +++ b/lang/ca/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName està fora de servei.', 'back_soon' => 'Aviat ho arreglarem.', + // 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.', + '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.', + '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 s’ha trobat cap testimoni d’autorització en aquesta sol·licitud.', 'api_bad_authorization_format' => 'S’ha trobat un testimoni d’autorització en aquesta sol·licitud però no tenia el format correcte.', diff --git a/lang/ca/settings.php b/lang/ca/settings.php index 46f92571e..2390a7962 100644 --- a/lang/ca/settings.php +++ b/lang/ca/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Accés a l’API del sistema', 'role_manage_settings' => 'Gestió de la configuració de l’aplicació', 'role_export_content' => 'Exportació de contingut', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Canvi de l’editor de pàgina', 'role_notifications' => 'Recepció i gestió de notificacions', 'role_asset' => 'Permisos de recursos', diff --git a/lang/ca/validation.php b/lang/ca/validation.php index dc9b02d50..e33ae336f 100644 --- a/lang/ca/validation.php +++ b/lang/ca/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'El format :attribute no és vàlid.', 'uploaded' => 'No s’ha pogut pujar el fitxer. És possible que el servidor no admeti fitxers d’aquesta mida.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/cs/activities.php b/lang/cs/activities.php index 412bf9d3b..3b4a05213 100644 --- a/lang/cs/activities.php +++ b/lang/cs/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'odstranil/a webhook', 'webhook_delete_notification' => 'Webhook byl úspěšně odstraněn', + // 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' => 'vytvořil uživatele', 'user_create_notification' => 'Uživatel byl úspěšně vytvořen', diff --git a/lang/cs/editor.php b/lang/cs/editor.php index f4c442af8..8e7001c08 100644 --- a/lang/cs/editor.php +++ b/lang/cs/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'O editoru', 'about_title' => 'O WYSIWYG editoru', 'editor_license' => 'Editor licence a autorská práva', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Tento editor je vytvořen pomocí :tinyLink, který je poskytován pod licencí MIT.', 'editor_tiny_license_link' => 'Podrobnosti o autorských právech a licenci TinyMCE naleznete zde.', 'save_continue' => 'Uložit stránku a pokračovat', diff --git a/lang/cs/entities.php b/lang/cs/entities.php index 77fb8a3fb..a87ba49d0 100644 --- a/lang/cs/entities.php +++ b/lang/cs/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF dokument', 'export_text' => 'Textový soubor', 'export_md' => 'Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Výchozí šablona stránky', 'default_template_explain' => 'Přiřadit šablonu stránky, která bude použita jako výchozí obsah pro všechny nové stránky v této knize. Mějte na paměti, že šablona bude použita pouze v případě, že tvůrce stránek bude mít přístup k těmto vybraným stránkám šablony.', 'default_template_select' => 'Vyberte šablonu stránky', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Oprávnění', diff --git a/lang/cs/errors.php b/lang/cs/errors.php index e23f1032b..c740ee193 100644 --- a/lang/cs/errors.php +++ b/lang/cs/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName je momentálně vypnutá', 'back_soon' => 'Brzy bude opět v provozu.', + // 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.', + '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.', + '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' => 'V požadavku nebyl nalezen žádný autorizační token', 'api_bad_authorization_format' => 'V požadavku byl nalezen autorizační token, ale jeho formát se zdá být chybný', diff --git a/lang/cs/settings.php b/lang/cs/settings.php index 01ea3a1db..67d7d825a 100644 --- a/lang/cs/settings.php +++ b/lang/cs/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Přístup k systémovému API', 'role_manage_settings' => 'Správa nastavení aplikace', 'role_export_content' => 'Exportovat obsah', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Změnit editor stránek', 'role_notifications' => 'Přijímat a spravovat oznámení', 'role_asset' => 'Obsahová oprávnění', diff --git a/lang/cs/validation.php b/lang/cs/validation.php index 83929cc88..f2ee59e5b 100644 --- a/lang/cs/validation.php +++ b/lang/cs/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Formát :attribute je neplatný.', 'uploaded' => 'Nahrávání :attribute se nezdařilo.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/cy/activities.php b/lang/cy/activities.php index b7f1cadf3..e5dcddc56 100644 --- a/lang/cy/activities.php +++ b/lang/cy/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhook wedi\'i dileu', 'webhook_delete_notification' => 'Webhook wedi\'i dileu\'n llwyddiannus', + // 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' => 'creodd ddefnyddiwr', 'user_create_notification' => 'Defnyddiwr wedi\'i greu\'n llwyddiannus', diff --git a/lang/cy/editor.php b/lang/cy/editor.php index c99a9bb00..a10fc7d0b 100644 --- a/lang/cy/editor.php +++ b/lang/cy/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Ynglŷn â\'r golygydd', 'about_title' => 'Ynglŷn â\'r Golygydd WYSIWYG', 'editor_license' => 'Trwydded a Hawlfraint Golygydd', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Mae\'r golygydd hwn wedi\'i adeiladu gan ddefnyddio :tinyLink sy\'n cael ei ddarparu o dan y drwydded MIT.', 'editor_tiny_license_link' => 'Gellir dod o hyd i fanylion hawlfraint a thrwydded TinyMCE yma.', 'save_continue' => 'Cadw Tudalen a Pharhau', diff --git a/lang/cy/entities.php b/lang/cy/entities.php index 1293231d0..d153ba453 100644 --- a/lang/cy/entities.php +++ b/lang/cy/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Ffeil PDF', 'export_text' => 'Ffeil Testun Plaen', 'export_md' => 'Ffeil Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Templed Tudalen Diofyn', 'default_template_explain' => 'Clustnodwch dempled tudalen a fydd yn cael ei ddefnyddio fel y cynnwys diofyn ar gyfer pob tudalen a grëwyd yn yr eitem hon. Cofiwch y bydd hwn ond yn cael ei ddefnyddio os yw’r sawl a grëodd y dudalen â mynediad gweld i’r dudalen dempled a ddewiswyd.', 'default_template_select' => 'Dewiswch dudalen templed', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Caniatâd', diff --git a/lang/cy/errors.php b/lang/cy/errors.php index b6868c5ce..4fe90a0c1 100644 --- a/lang/cy/errors.php +++ b/lang/cy/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => 'Mae :appName i lawr ar hyn o bryd', 'back_soon' => 'Bydd yn ôl i fyny yn fuan.', + // 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.', + '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.', + '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' => 'Ni chanfuwyd tocyn awdurdodi ar y cais', 'api_bad_authorization_format' => 'Canfuwyd tocyn awdurdodi ar y cais ond roedd yn ymddangos bod y fformat yn anghywir', diff --git a/lang/cy/settings.php b/lang/cy/settings.php index 3b0fa5ed6..92b2d65d9 100644 --- a/lang/cy/settings.php +++ b/lang/cy/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Mynediad i Ryngwyneb Rhaglennu Cymwysiadau (API) system', 'role_manage_settings' => 'Rheoli gosodiadau apiau', 'role_export_content' => 'Cynnwys allforio', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Newid golygydd tudalen', 'role_notifications' => 'Derbyn a rheoli hysbysiadau', 'role_asset' => 'Caniatâd Asedau', diff --git a/lang/cy/validation.php b/lang/cy/validation.php index 006641841..fd3483ddc 100644 --- a/lang/cy/validation.php +++ b/lang/cy/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Mae’r fformat :attribute yn annilys.', 'uploaded' => 'Nid oedd modd uwchlwytho’r ffeil. Efallai na fydd y gweinydd yn derbyn ffeiliau o\'r maint hwn.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/da/activities.php b/lang/da/activities.php index e92d1c165..f6053318f 100644 --- a/lang/da/activities.php +++ b/lang/da/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'slettede webhooken', 'webhook_delete_notification' => 'Webhooken blev slettet', + // 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' => 'opret bruger', 'user_create_notification' => 'Bruger oprettet korrekt', diff --git a/lang/da/editor.php b/lang/da/editor.php index 973c8a902..054c42627 100644 --- a/lang/da/editor.php +++ b/lang/da/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Gem og fortsæt', diff --git a/lang/da/entities.php b/lang/da/entities.php index 47d2f1568..eca5f1f02 100644 --- a/lang/da/entities.php +++ b/lang/da/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF-fil', 'export_text' => 'Almindelig tekstfil', 'export_md' => 'Markdown Fil', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Rettigheder', diff --git a/lang/da/errors.php b/lang/da/errors.php index afa7b5824..f12dbab57 100644 --- a/lang/da/errors.php +++ b/lang/da/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName er nede lige nu', 'back_soon' => 'Den er oppe igen snart.', + // 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.', + '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.', + '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' => 'Der blev ikke fundet nogen autorisationstoken på anmodningen', 'api_bad_authorization_format' => 'En autorisationstoken blev fundet på anmodningen, men formatet var forkert', diff --git a/lang/da/settings.php b/lang/da/settings.php index fa802af16..36ab18d30 100644 --- a/lang/da/settings.php +++ b/lang/da/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Tilgå system-API', 'role_manage_settings' => 'Administrer app-indstillinger', 'role_export_content' => 'Eksporter indhold', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Skift side editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Tilladelser for medier og "assets"', diff --git a/lang/da/validation.php b/lang/da/validation.php index 022839dc6..b09b621b2 100644 --- a/lang/da/validation.php +++ b/lang/da/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute-formatet er ugyldigt.', 'uploaded' => 'Filen kunne ikke oploades. Serveren accepterer muligvis ikke filer af denne størrelse.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/de/activities.php b/lang/de/activities.php index 509920947..79e05fbaf 100644 --- a/lang/de/activities.php +++ b/lang/de/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'löschte Webhook', 'webhook_delete_notification' => 'Webhook erfolgreich gelöscht', + // 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' => 'hat Benutzer erzeugt:', 'user_create_notification' => 'Benutzer erfolgreich erstellt', diff --git a/lang/de/editor.php b/lang/de/editor.php index e6b240069..ab19d167e 100644 --- a/lang/de/editor.php +++ b/lang/de/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Über den Editor', 'about_title' => 'Über den WYSIWYG-Editor', 'editor_license' => 'Editorlizenz & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Dieser Editor wurde mithilfe von :tinyLink erstellt, der unter der MIT-Lizenz bereitgestellt wird.', 'editor_tiny_license_link' => 'Die Copyright- und Lizenzdetails von TinyMCE finden Sie hier.', 'save_continue' => 'Speichern & Fortfahren', diff --git a/lang/de/entities.php b/lang/de/entities.php index 6c67d9830..b3e1b38f9 100644 --- a/lang/de/entities.php +++ b/lang/de/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF-Datei', 'export_text' => 'Textdatei', 'export_md' => 'Markdown-Datei', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Standard Seitenvorlage', 'default_template_explain' => 'Zuweisen einer Seitenvorlage, die als Standardinhalt für alle Seiten verwendet wird, die innerhalb dieses Elements erstellt wurden. Beachten Sie, dass dies nur dann verwendet wird, wenn der Ersteller der Seite Zugriff auf die ausgewählte Vorlagen-Seite hat.', 'default_template_select' => 'Wählen Sie eine Seitenvorlage', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Berechtigungen', diff --git a/lang/de/errors.php b/lang/de/errors.php index 7d6e09935..f940f6743 100644 --- a/lang/de/errors.php +++ b/lang/de/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName befindet sich aktuell im Wartungsmodus', 'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.', + // 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.', + '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.', + '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' => 'Kein Autorisierungstoken für die Anfrage gefunden', 'api_bad_authorization_format' => 'Ein Autorisierungstoken wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein', diff --git a/lang/de/settings.php b/lang/de/settings.php index 60fc25914..c1e4bcdd9 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -163,6 +163,7 @@ Hinweis: Benutzer können ihre E-Mail-Adresse nach erfolgreicher Registrierung 'role_access_api' => 'Systemzugriffs-API', 'role_manage_settings' => 'Globaleinstellungen verwalten', 'role_export_content' => 'Inhalt exportieren', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Seiten-Editor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', 'role_asset' => 'Berechtigungen', diff --git a/lang/de/validation.php b/lang/de/validation.php index 1ca1f458e..072bf79d1 100644 --- a/lang/de/validation.php +++ b/lang/de/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute ist kein valides Format.', 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/de_informal/activities.php b/lang/de_informal/activities.php index 6783b42a5..94a0b2b1d 100644 --- a/lang/de_informal/activities.php +++ b/lang/de_informal/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'gelöschter Webhook', 'webhook_delete_notification' => 'Webhook erfolgreich gelöscht', + // Imports + 'import_create' => 'erstellter Import', + 'import_create_notification' => 'Import erfolgreich hochgeladen', + 'import_run' => 'aktualisierter Import', + 'import_run_notification' => 'Inhalt erfolgreich importiert', + 'import_delete' => 'gelöschter Import', + 'import_delete_notification' => 'Import erfolgreich gelöscht', + // Users 'user_create' => 'hat Benutzer erzeugt:', 'user_create_notification' => 'Benutzer erfolgreich erstellt', diff --git a/lang/de_informal/common.php b/lang/de_informal/common.php index e033b63d2..bc33b8535 100644 --- a/lang/de_informal/common.php +++ b/lang/de_informal/common.php @@ -109,5 +109,5 @@ return [ 'terms_of_service' => 'Allgemeine Geschäftsbedingungen', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Suche :appName', ]; diff --git a/lang/de_informal/editor.php b/lang/de_informal/editor.php index 1956715d1..81f5eab9e 100644 --- a/lang/de_informal/editor.php +++ b/lang/de_informal/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Über den Editor', 'about_title' => 'Über den WYSIWYG Editor', 'editor_license' => 'Editorlizenz & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Dieser Editor wurde mit :tinyLink erstellt, das unter der MIT-Lizenz zur Verfügung gestellt wird.', 'editor_tiny_license_link' => 'Die Copyright- und Lizenzdetails von TinyMCE findest du hier.', 'save_continue' => 'Seite speichern & fortfahren', diff --git a/lang/de_informal/entities.php b/lang/de_informal/entities.php index e8a691672..4283fb8cb 100644 --- a/lang/de_informal/entities.php +++ b/lang/de_informal/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF-Datei', 'export_text' => 'Textdatei', 'export_md' => 'Markdown-Datei', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Standard Seitenvorlage', 'default_template_explain' => 'Zuweisen einer Seitenvorlage, die als Standardinhalt für alle Seiten verwendet wird, die innerhalb dieses Elements erstellt wurden. Beachten Sie, dass dies nur dann verwendet wird, wenn der Ersteller der Seite Zugriff auf die ausgewählte Vorlagen-Seite hat.', 'default_template_select' => 'Wähle eine Seitenvorlage', + 'import' => 'Importieren', + 'import_validate' => 'Import validieren', + 'import_desc' => 'Importiere Bücher, Kapitel & Seiten mit einem ZIP-Export von der gleichen oder einer anderen Instanz. Wähle eine ZIP-Datei, um fortzufahren. Nachdem die Datei hochgeladen und bestätigt wurde, kannst Du den Import in der nächsten Ansicht konfigurieren und bestätigen.', + 'import_zip_select' => 'ZIP-Datei zum Hochladen auswählen', + 'import_zip_validation_errors' => 'Fehler bei der Validierung der angegebenen ZIP-Datei:', + 'import_pending' => 'Ausstehende Importe', + 'import_pending_none' => 'Es wurden keine Importe gestartet.', + 'import_continue' => 'Import fortsetzen', + 'import_continue_desc' => 'Überprüfe den Inhalt, der aus der hochgeladenen ZIP-Datei importiert werden soll. Führe den Import aus, um dessen Inhalt zu diesem System hinzuzufügen. Die hochgeladene ZIP-Importdatei wird bei erfolgreichem Import automatisch gelöscht.', + 'import_details' => 'Importdetails', + 'import_run' => 'Import ausführen', + 'import_size' => 'Größe des importierten ZIP: :size', + 'import_uploaded_at' => 'Hochgeladen :relativeTime', + 'import_uploaded_by' => 'Hochgeladen von', + 'import_location' => 'Import Zielort', + 'import_location_desc' => 'Wähle einen Zielort für deinen importierten Inhalt. Du benötigst die entsprechenden Berechtigungen, um innerhalb des gewünschten Zielortes Inhalte zu erstellen.', + 'import_delete_confirm' => 'Bist Du sicher, dass Du diesen Import löschen möchtest?', + 'import_delete_desc' => 'Dies löscht die hochgeladene ZIP-Datei und kann nicht rückgängig gemacht werden.', + 'import_errors' => 'Importfehler', + 'import_errors_desc' => 'Die folgenden Fehler sind während des Importversuchs aufgetreten:', // Permissions and restrictions 'permissions' => 'Berechtigungen', @@ -224,8 +245,8 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(Sauberer Inhalt)', 'pages_edit_switch_to_markdown_stable' => '(Stabiler Inhalt)', 'pages_edit_switch_to_wysiwyg' => 'Zum WYSIWYG Editor wechseln', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Wechsel zum neuen WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testphase)', 'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen', 'pages_edit_enter_changelog_desc' => 'Bitte gib eine kurze Zusammenfassung deiner Änderungen ein', 'pages_edit_enter_changelog' => 'Änderungsprotokoll eingeben', diff --git a/lang/de_informal/errors.php b/lang/de_informal/errors.php index dcfa6a2ff..856f02b48 100644 --- a/lang/de_informal/errors.php +++ b/lang/de_informal/errors.php @@ -78,7 +78,7 @@ return [ // Users 'users_cannot_delete_only_admin' => 'Du kannst den einzigen Administrator nicht löschen.', 'users_cannot_delete_guest' => 'Du kannst den Gast-Benutzer nicht löschen', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Benutzer konnte nicht erstellt werden, da die Einladungs-E-Mail nicht gesendet werden konnte', // Roles 'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden.', @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName befindet sich aktuell im Wartungsmodus.', 'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.', + // Import + 'import_zip_cant_read' => 'ZIP-Datei konnte nicht gelesen werden.', + 'import_zip_cant_decode_data' => 'Konnte Inhalt der data.json im ZIP nicht finden und dekodieren.', + 'import_zip_no_data' => 'ZIP-Datei hat kein erwartetes Buch, Kapitel oder Seiteninhalt.', + 'import_validation_failed' => 'ZIP Import konnte aufgrund folgender Fehler nicht validiert werden:', + 'import_zip_failed_notification' => 'Importieren der ZIP-Datei fehlgeschlagen.', + 'import_perms_books' => 'Dir fehlt die erforderliche Berechtigung, um Bücher zu erstellen.', + 'import_perms_chapters' => 'Dir fehlt die erforderliche Berechtigung, um Kapitel zu erstellen.', + 'import_perms_pages' => 'Dir fehlt die erforderliche Berechtigung, um Seiten zu erstellen.', + 'import_perms_images' => 'Dir fehlt die erforderliche Berechtigung, um Bilder zu erstellen.', + 'import_perms_attachments' => 'Dir fehlt die erforderliche Berechtigung, um Anhänge zu erstellen.', + // API errors 'api_no_authorization_found' => 'Kein Autorisierungs-Token für die Anfrage gefunden', 'api_bad_authorization_format' => 'Ein Autorisierungs-Token wurde auf die Anfrage gefunden, aber das Format schien falsch zu sein', diff --git a/lang/de_informal/settings.php b/lang/de_informal/settings.php index 2eddefd4a..549aba114 100644 --- a/lang/de_informal/settings.php +++ b/lang/de_informal/settings.php @@ -163,6 +163,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung 'role_access_api' => 'Systemzugriffs-API', 'role_manage_settings' => 'Globaleinstellungen verwalten', 'role_export_content' => 'Inhalt exportieren', + 'role_import_content' => 'Inhalt importieren', 'role_editor_change' => 'Seiteneditor ändern', 'role_notifications' => 'Empfangen und Verwalten von Benachrichtigungen', 'role_asset' => 'Berechtigungen', diff --git a/lang/de_informal/validation.php b/lang/de_informal/validation.php index 61cd7409b..0d26dfd29 100644 --- a/lang/de_informal/validation.php +++ b/lang/de_informal/validation.php @@ -75,7 +75,7 @@ return [ ], 'mimes' => ':attribute muss eine Datei vom Typ: :values sein.', 'min' => [ - 'numeric' => ':attribute muss mindestens :min sein', + 'numeric' => ':attribute muss mindestens :min sein.', 'file' => ':attribute muss mindestens :min Kilobyte groß sein.', 'string' => ':attribute muss mindestens :min Zeichen lang sein.', 'array' => ':attribute muss mindesten :min Elemente enthalten.', @@ -105,6 +105,11 @@ return [ 'url' => ':attribute ist kein valides Format.', 'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.', + 'zip_file' => ':attribute muss auf eine Datei innerhalb des ZIP verweisen.', + 'zip_file_mime' => ':attribute muss auf eine Datei des Typs :validType verweisen, gefunden :foundType.', + 'zip_model_expected' => 'Datenobjekt erwartet, aber ":type" gefunden.', + 'zip_unique' => ':attribute muss für den Objekttyp innerhalb des ZIP eindeutig sein.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/el/activities.php b/lang/el/activities.php index 50bd7bb2e..6eb857464 100644 --- a/lang/el/activities.php +++ b/lang/el/activities.php @@ -14,7 +14,7 @@ return [ 'page_delete_notification' => 'Η σελίδα διαγράφηκε επιτυχώς', 'page_restore' => 'αποκατεστημένη σελίδα', 'page_restore_notification' => 'Η σελίδα αποκαταστάθηκε με επιτυχία', - 'page_move' => 'Η σελίδα μετακινήθηκε', + 'page_move' => 'σελίδα που μετακινήθηκε', 'page_move_notification' => 'Η σελίδα μετακινήθηκε με επιτυχία', // Chapters @@ -59,7 +59,7 @@ return [ 'favourite_remove_notification' => '":name" προστέθηκε στα αγαπημένα σας', // Watching - 'watch_update_level_notification' => 'Watch preferences successfully updated', + 'watch_update_level_notification' => 'Οι προτιμήσεις παρακολούθησης ενημερώθηκαν επιτυχώς', // Auth 'auth_login' => 'συνδεδεμένος', @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'διαγραμμένο webhook', 'webhook_delete_notification' => 'Το Webhook διαγράφηκε επιτυχώς', + // Imports + 'import_create' => 'δημιουργημένη εισαγωγή', + 'import_create_notification' => 'Το εισαγόμενο αρχείο μεταφορτώθηκε επιτυχώς', + 'import_run' => 'ενημερωμένη εισαγωγή', + 'import_run_notification' => 'Το περιεχόμενο εισήχθη επιτυχώς', + 'import_delete' => 'διαγραμμένη εισαγωγή', + 'import_delete_notification' => 'Η εισαγωγή διαγράφηκε επιτυχώς', + // Users 'user_create' => 'δημιουργημένος χρήστης', 'user_create_notification' => 'Ο χρήστης δημιουργήθηκε με επιτυχία', @@ -105,19 +113,19 @@ return [ 'role_create_notification' => 'Ο Ρόλος δημιουργήθηκε με επιτυχία', 'role_update' => 'Ενημέρωση ρόλου', 'role_update_notification' => 'Ο Ρόλος ενημερώθηκε με επιτυχία', - 'role_delete' => 'deleted role', + 'role_delete' => 'διαγραμμένος ρόλος', 'role_delete_notification' => 'Ο Ρόλος διαγράφηκε επιτυχώς', // Recycle Bin - 'recycle_bin_empty' => 'emptied recycle bin', - 'recycle_bin_restore' => 'restored from recycle bin', - 'recycle_bin_destroy' => 'removed from recycle bin', + 'recycle_bin_empty' => 'αδειασμένος κάδος ανακύκλωσης', + 'recycle_bin_restore' => 'αποκαταστάθηκε από τον κάδο ανακύκλωσης', + 'recycle_bin_destroy' => 'αφαιρέθηκε από τον κάδο ανακύκλωσης', // Comments 'commented_on' => 'σχολίασε', - 'comment_create' => 'added comment', - 'comment_update' => 'updated comment', - 'comment_delete' => 'deleted comment', + 'comment_create' => 'προστέθηκε σχόλιο', + 'comment_update' => 'ενημερώθηκε σχόλιο', + 'comment_delete' => 'διαγράφηκε σχόλιο', // Other 'permissions_update' => 'ενημερωμένα δικαιώματα', diff --git a/lang/el/auth.php b/lang/el/auth.php index 183baab7a..0b94ef859 100644 --- a/lang/el/auth.php +++ b/lang/el/auth.php @@ -91,7 +91,7 @@ return [ 'mfa_option_totp_title' => 'Εφαρμογή για κινητό', 'mfa_option_totp_desc' => 'Για να χρησιμοποιήσετε τον έλεγχο ταυτότητας πολλαπλών παραγόντων, θα χρειαστείτε μια εφαρμογή για κινητά που υποστηρίζει TOTP, όπως το Google Authenticator, το Authy ή το Microsoft Authenticator.', 'mfa_option_backup_codes_title' => 'Εφεδρικοί κωδικοί', - 'mfa_option_backup_codes_desc' => 'Generates a set of one-time-use backup codes which you\'ll enter on login to verify your identity. Make sure to store these in a safe & secure place.', + 'mfa_option_backup_codes_desc' => 'Δημιουργεί ένα σύνολο εφεδρικών κωδικών μίας χρήσης τους οποίους θα εισάγετε κατά τη σύνδεση σας, για να πιστοποιήσετε την ταυτότητά σας. Φροντίστε να τους αποθηκεύσετε σε ασφαλές σημείο.', 'mfa_gen_confirm_and_enable' => 'Επιβεβαίωση και ενεργοποίηση', 'mfa_gen_backup_codes_title' => 'Ρύθμιση εφεδρικών κωδικών', 'mfa_gen_backup_codes_desc' => 'Αποθηκεύστε την παρακάτω λίστα κωδικών σε ασφαλές μέρος. Κατά την πρόσβαση στο σύστημα, θα μπορείτε να χρησιμοποιήσετε έναν από τους κωδικούς ως δεύτερο μηχανισμό ελέγχου ταυτότητας.', diff --git a/lang/el/common.php b/lang/el/common.php index d1b6bd9f7..112133883 100644 --- a/lang/el/common.php +++ b/lang/el/common.php @@ -6,7 +6,7 @@ return [ // Buttons 'cancel' => 'Ακύρωση', - 'close' => 'Close', + 'close' => 'Κλείσιμο', 'confirm' => 'Οκ', 'back' => 'Πίσω', 'save' => 'Αποθήκευση', @@ -20,7 +20,7 @@ return [ 'description' => 'Περιγραφή', 'role' => 'Ρόλος', 'cover_image' => 'Εικόνα εξώφυλλου', - 'cover_image_description' => 'This image should be approximately 440x250px although it will be flexibly scaled & cropped to fit the user interface in different scenarios as required, so actual dimensions for display will differ.', + 'cover_image_description' => 'Αυτή η εικόνα θα πρέπει να είναι περίπου 440x250px, αν και θα κλιμακωθεί και θα περικοπεί με ευελιξία ώστε να ταιριάζει στη διεπαφή χρήστη σε διαφορετικά σενάρια όπως απαιτείται, επομένως οι πραγματικές διαστάσεις για την εμφάνιση θα διαφέρουν.', // Actions 'actions' => 'Ενέργειες', @@ -42,7 +42,7 @@ return [ 'remove' => 'Αφαίρεση', 'add' => 'Προσθήκη', 'configure' => 'Διαμόρφωση', - 'manage' => 'Manage', + 'manage' => 'Διαχείριση', 'fullscreen' => 'Πλήρης οθόνη', 'favourite' => 'Αγαπημένα', 'unfavourite' => 'Αφαίρεση από Αγαπημένα', @@ -52,7 +52,7 @@ return [ 'filter_clear' => 'Διαγραφή φίλτρου', 'download' => 'Λήψη', 'open_in_tab' => 'Άνοιγμα σε Καρτέλα', - 'open' => 'Open', + 'open' => 'Άνοιγμα', // Sort Options 'sort_options' => 'Επιλογές ταξινόμησης', @@ -109,5 +109,5 @@ return [ 'terms_of_service' => 'Όροι χρήσης', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Αναζήτηση :appName', ]; diff --git a/lang/el/components.php b/lang/el/components.php index b54a48601..9c9d3e8f8 100644 --- a/lang/el/components.php +++ b/lang/el/components.php @@ -6,36 +6,36 @@ return [ // Image Manager 'image_select' => 'Επιλογή εικόνας', - 'image_list' => 'Image List', - 'image_details' => 'Image Details', - 'image_upload' => 'Upload Image', - 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', - 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', + 'image_list' => 'Κατάλογος εικόνων', + 'image_details' => 'Λεπτομέρειες εικόνας', + 'image_upload' => 'Μεταφόρτωση εικόνας', + 'image_intro' => 'Εδώ μπορείτε να επιλέξετε και να διαχειριστείτε εικόνες που έχουν προηγουμένως μεταφορτωθεί στο σύστημα.', + 'image_intro_upload' => 'Μεταφορτώστε μια νέα εικόνα σύροντας ένα αρχείο εικόνας σε αυτό το παράθυρο ή χρησιμοποιώντας το κουμπί "Μεταφόρτωση εικόνας" παραπάνω.', 'image_all' => 'Όλες', 'image_all_title' => 'Δείτε όλες τις εικόνες που υπάρχουν στο Server', 'image_book_title' => 'Προβολή εικόνων που έχουν μεταφορτωθεί σε αυτό το βιβλίο', 'image_page_title' => 'Προβολή εικόνων που έχουν δημοσιευτεί σε αυτήν τη σελίδα', 'image_search_hint' => 'Αναζήτηση με όνομα εικόνας', 'image_uploaded' => 'Μεταφορτώθηκε :uploadedDate', - 'image_uploaded_by' => 'Uploaded by :userName', - 'image_uploaded_to' => 'Uploaded to :pageLink', - 'image_updated' => 'Updated :updateDate', + 'image_uploaded_by' => 'Μεταφορτώθηκε από :userName', + 'image_uploaded_to' => 'Μεταφορτώθηκε στο :pageLink', + 'image_updated' => 'Ενημερώθηκε :updateDate', 'image_load_more' => 'Φόρτωσε περισσότερα', 'image_image_name' => 'Όνομα εικόνας', 'image_delete_used' => 'Αυτή η εικόνα χρησιμοποιείται στις παρακάτω σελίδες.', 'image_delete_confirm_text' => 'Είστε σίγουροι ότι θέλετε να διαγράψετε αυτήν την εικόνα;', 'image_select_image' => 'Επιλέξτε Εικόνα', 'image_dropzone' => 'Σύρτε ή κάντε κλικ εδώ για μεταφόρτωση εικόνων', - 'image_dropzone_drop' => 'Drop images here to upload', + 'image_dropzone_drop' => 'Απόθεση εικόνων εδώ για μεταφόρτωση', 'images_deleted' => 'Οι εικόνες διαγράφηκαν', 'image_preview' => 'Προεπισκόπηση εικόνας', 'image_upload_success' => 'Η εικόνα μεταφορτώθηκε με επιτυχία', 'image_update_success' => 'Τα στοιχεία της εικόνας ενημερώθηκαν με επιτυχία', 'image_delete_success' => 'Η εικόνα διαγράφηκε επιτυχώς', - 'image_replace' => 'Replace Image', - 'image_replace_success' => 'Image file successfully updated', - 'image_rebuild_thumbs' => 'Regenerate Size Variations', - 'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!', + 'image_replace' => 'Αντικατάσταση εικόνας', + 'image_replace_success' => 'Το αρχείο εικόνας ενημερώθηκε επιτυχώς', + 'image_rebuild_thumbs' => 'Επαναδημιουργία παραλλαγών μεγέθους', + 'image_rebuild_thumbs_success' => 'Οι παραλλαγές μεγέθους εικόνας ανακατασκευάστηκαν επιτυχώς!', // Code Editor 'code_editor' => 'Επεξεργασία κώδικα', diff --git a/lang/el/editor.php b/lang/el/editor.php index 0d801e821..f6c67b879 100644 --- a/lang/el/editor.php +++ b/lang/el/editor.php @@ -33,7 +33,7 @@ return [ 'header_small' => 'Μικρή κεφαλίδα', 'header_tiny' => 'Μικροσκοπική κεφαλίδα', 'paragraph' => 'Παράγραφος', - 'blockquote' => 'Blockquote', + 'blockquote' => 'Μπλοκ κειμένου παράθεσης', 'inline_code' => 'Ενσωματωμένος κωδικός', 'callouts' => 'Επεξηγήσεις', 'callout_information' => 'Πληροφορίες', @@ -57,7 +57,7 @@ return [ 'list_bullet' => 'Λίστα με κουκκίδες', 'list_numbered' => 'Λίστα με αρίθμηση', 'list_task' => 'Λίστα εργασιών', - 'indent_increase' => 'Αύξηση εσοχήςt', + 'indent_increase' => 'Αύξηση Εσοχής', 'indent_decrease' => 'Μείωση εσοχής', 'table' => 'Πίνακας', 'insert_image' => 'Εισαγωγή εικόνας', @@ -81,9 +81,9 @@ return [ 'table_properties' => 'Ιδιότητες πίνακα', 'table_properties_title' => 'Ιδιότητες πίνακα', 'delete_table' => 'Διαγραφή πίνακα', - 'table_clear_formatting' => 'Clear table formatting', - 'resize_to_contents' => 'Resize to contents', - 'row_header' => 'Row header', + 'table_clear_formatting' => 'Εκκαθάριση μορφοποίησης πίνακα', + 'resize_to_contents' => 'Αλλαγή μεγέθους σε περιεχόμενο', + 'row_header' => 'Κεφαλίδα γραμμής', 'insert_row_before' => 'Εισαγωγή γραμμής πάνω', 'insert_row_after' => 'Εισαγωγή γραμμής κάτω', 'delete_row' => 'Διαγραφή γραμμής', @@ -163,6 +163,8 @@ return [ 'about' => 'Σχετικά', 'about_title' => 'Σχετικά με τον επεξεργαστή WYSIWYG', 'editor_license' => 'Άδεια εκδότη και πνευματικά δικαιώματα', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Αυτός ο επεξεργαστής έχει δημιουργηθεί χρησιμοποιώντας :tinyLink που παρέχεται με την άδεια MIT.', 'editor_tiny_license_link' => 'Τα πνευματικά δικαιώματα και τα στοιχεία άδειας χρήσης του TinyMCE μπορείτε να τα βρείτε εδώ.', 'save_continue' => 'Αποθήκευση σελίδας & Συνέχεια', diff --git a/lang/el/entities.php b/lang/el/entities.php index d127a77c2..d74c7de05 100644 --- a/lang/el/entities.php +++ b/lang/el/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Αρχείο PDF', 'export_text' => 'Αρχείο Απλού κειμένου', 'export_md' => 'Αρχείο Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Δικαιώματα', diff --git a/lang/el/errors.php b/lang/el/errors.php index 1d57fe4a1..cdfa2155f 100644 --- a/lang/el/errors.php +++ b/lang/el/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName είναι προσωρινά μη διαθέσιμη', 'back_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.', + '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.', + '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' => 'Δεν βρέθηκε διακριτικό εξουσιοδότησης κατόπιν αιτήματος', 'api_bad_authorization_format' => 'Ένα διακριτικό εξουσιοδότησης βρέθηκε κατόπιν αιτήματος, αλλά η μορφή εμφανίστηκε εσφαλμένη', diff --git a/lang/el/notifications.php b/lang/el/notifications.php index 1afd23f1d..3924cab3b 100644 --- a/lang/el/notifications.php +++ b/lang/el/notifications.php @@ -4,24 +4,24 @@ */ return [ - 'new_comment_subject' => 'New comment on page: :pageName', - 'new_comment_intro' => 'A user has commented on a page in :appName:', - 'new_page_subject' => 'New page: :pageName', - 'new_page_intro' => 'A new page has been created in :appName:', - 'updated_page_subject' => 'Updated page: :pageName', - 'updated_page_intro' => 'A page has been updated in :appName:', - 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + 'new_comment_subject' => 'Νέο σχόλιο στη σελίδα: :pageName', + 'new_comment_intro' => 'Ένας χρήστης έχει σχολιάσει σε μια σελίδα στο :appName:', + 'new_page_subject' => 'Νέα σελίδα: :pageName', + 'new_page_intro' => 'Μια νέα σελίδα έχει δημιουργηθεί στο :appName:', + 'updated_page_subject' => 'Ενημερωμένη σελίδα: :pageName', + 'updated_page_intro' => 'Μια σελίδα έχει ενημερωθεί στο :appName:', + 'updated_page_debounce' => 'Για να αποτρέψετε μαζικές ειδοποιήσεις, για κάποιο διάστημα δε θα σας αποστέλλονται ειδοποιήσεις για περαιτέρω αλλαγές σε αυτήν τη σελίδα από τον ίδιο συντάκτη.', - 'detail_page_name' => 'Page Name:', - 'detail_page_path' => 'Page Path:', - 'detail_commenter' => 'Commenter:', - 'detail_comment' => 'Comment:', - 'detail_created_by' => 'Created By:', - 'detail_updated_by' => 'Updated By:', + 'detail_page_name' => 'Όνομα σελίδας:', + 'detail_page_path' => 'Διαδρομή σελίδας:', + 'detail_commenter' => 'Σχολιαστής:', + 'detail_comment' => 'Σχόλιο:', + 'detail_created_by' => 'Δημιουργήθηκε από:', + 'detail_updated_by' => 'Ενημερώθηκε από:', - 'action_view_comment' => 'View Comment', - 'action_view_page' => 'View Page', + 'action_view_comment' => 'Προβολή σχολίου', + 'action_view_page' => 'Προβολή σελίδας', - 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', - 'footer_reason_link' => 'your notification preferences', + 'footer_reason' => 'Αυτή η ειδοποίηση εστάλη σε εσάς επειδή το :link καλύπτει αυτόν τον τύπο δραστηριότητας για αυτό το αντικείμενο.', + 'footer_reason_link' => 'προτιμήσεις σας για ειδοποιήσεις', ]; diff --git a/lang/el/settings.php b/lang/el/settings.php index 8c34fccba..243d1c288 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -109,7 +109,7 @@ return [ 'recycle_bin_contents_empty' => 'Ο κάδος ανακύκλωσης είναι επί του παρόντος άδειος', 'recycle_bin_empty' => 'Αδειάστε τον Κάδο Ανακύκλωσης', 'recycle_bin_empty_confirm' => 'Αυτό θα καταστρέψει οριστικά όλα τα αντικείμενα στον κάδο ανακύκλωσης, συμπεριλαμβανομένου του περιεχομένου που περιέχεται σε κάθε αντικείμενο. Είστε βέβαιοι ότι θέλετε να αδειάσετε τον κάδο ανακύκλωσης;', - 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_confirm' => 'Αυτή η ενέργεια θα διαγράψει οριστικά από το σύστημα αυτό το στοιχείο μαζί με τυχόν θυγατρικά, που αναφέρονται παρακάτω. Μετά την επιβεβαίωση της διαγραφής δε θα μπορείτε να επαναφέρετε αυτό το περιεχόμενο. Είστε βέβαιοι ότι θέλετε να διαγράψετε οριστικά αυτό το στοιχείο;', 'recycle_bin_destroy_list' => 'Αντικείμενα για καταστροφή', 'recycle_bin_restore_list' => 'Αντικείμενα για επαναφορά', 'recycle_bin_restore_confirm' => 'Αυτή η ενέργεια θα επαναφέρει το διαγραμμένο στοιχείο, συμπεριλαμβανομένων τυχόν θυγατρικών στοιχείων, στην αρχική τους θέση. Εάν η αρχική τοποθεσία έχει από τότε διαγραφεί και βρίσκεται τώρα στον κάδο ανακύκλωσης, θα πρέπει επίσης να αποκατασταθεί και το γονικό στοιχείο.', @@ -162,8 +162,9 @@ return [ 'role_access_api' => 'Πρόσβαση στο API του συστήματος', 'role_manage_settings' => 'Διαχειριστείτε τις ρυθμίσεις του ΑΡΙ', 'role_export_content' => 'Εξαγωγή περιεχομένου', + 'role_import_content' => 'Εισαγωγή περιεχομένου', 'role_editor_change' => 'Αλλαγή προγράμματος επεξεργασίας σελίδας', - 'role_notifications' => 'Receive & manage notifications', + 'role_notifications' => 'Λήψη & διαχείριση ειδοποιήσεων', 'role_asset' => 'Δικαιώματα Συστήματος', 'roles_system_warning' => 'Λάβετε υπόψη ότι η πρόσβαση σε οποιοδήποτε από τις τρεις παραπάνω άδειες (δικαιώματα) μπορεί να επιτρέψει σε έναν χρήστη να αλλάξει τα δικά του προνόμια ή τα προνόμια άλλων στο σύστημα. Εκχωρήστε ρόλους με αυτά τα δικαιώματα μόνο σε αξιόπιστους χρήστες.', 'role_asset_desc' => 'Αυτά τα δικαιώματα ελέγχουν την προεπιλεγμένη πρόσβαση στα στοιχεία (άδειες) εντός του συστήματος. Τα δικαιώματα σε Βιβλία, Κεφάλαια και Σελίδες θα παρακάμψουν αυτές τις άδειες.', @@ -193,8 +194,8 @@ return [ 'users_send_invite_text' => 'Μπορείτε να επιλέξετε να στείλετε σε αυτόν τον χρήστη ένα email πρόσκλησης που του επιτρέπει να ορίσει τον δικό του κωδικό πρόσβασης. Σε διαφορετική περίπτωση μπορείτε να ορίσετε τον κωδικό πρόσβασής του εσείς.', 'users_send_invite_option' => 'Αποστολή email πρόσκλησης σε χρήστη', 'users_external_auth_id' => 'Εξωτερικός έλεγχος ταυτότητας', - 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', - 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_external_auth_id_desc' => 'Όταν χρησιμοποιείται ένα εξωτερικό σύστημα ελέγχου ταυτότητας (όπως SAML2, OIDC ή LDAP) αυτό είναι το αναγνωριστικό που συνδέει αυτόν τον χρήστη BookStack με τον λογαριασμό συστήματος ελέγχου ταυτότητας. Μπορείτε να αγνοήσετε αυτό το πεδίο αν χρησιμοποιείτε τον προεπιλεγμένο έλεγχο ταυτότητας μέσω email.', + 'users_password_warning' => 'Συμπληρώστε τα παρακάτω μόνο αν θέλετε να αλλάξετε τον κωδικό πρόσβασης για αυτόν το χρήστη.', 'users_system_public' => 'Αυτός ο χρήστης αντιπροσωπεύει οποιονδήποτε επισκέπτη που επισκέπτεται τη Βιβλιοθήκη σας. Δεν μπορεί να χρησιμοποιηθεί για τη σύνδεση αλλά εκχωρείται αυτόματα.', 'users_delete' => 'Διαγραφή Χρήστη', 'users_delete_named' => 'Διαγραφή χρήστη :userName', @@ -210,16 +211,16 @@ return [ 'users_preferred_language' => 'Προτιμώμενη γλώσσα', 'users_preferred_language_desc' => 'Αυτή η επιλογή θα αλλάξει τη γλώσσα που χρησιμοποιείται για τη διεπαφή χρήστη της εφαρμογής. Αυτό δεν θα επηρεάσει οποιοδήποτε περιεχόμενο που δημιουργήθηκε από χρήστες.', 'users_social_accounts' => 'Λογαριασμοί Κοινωνικής δικτύωσης ', - 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_desc' => 'Δείτε την κατάσταση των συνδεδεμένων λογαριασμών κοινωνικών δικτύων για αυτόν το χρήστη. Οι λογαριασμοί κοινωνικών δικτύων, μπορούν να χρησιμοποιηθούν επιπλέον του κύριου συστήματος ελέγχου ταυτότητας, για πρόσβαση στο σύστημα.', 'users_social_accounts_info' => 'Εδώ μπορείτε να συνδέσετε τους άλλους λογαριασμούς σας για ταχύτερη και ευκολότερη σύνδεση. Η αποσύνδεση ενός λογαριασμού εδώ δεν ανακαλεί προηγουμένως εξουσιοδοτημένη πρόσβαση. Ανάκληση πρόσβασης από τις ρυθμίσεις προφίλ σας στον συνδεδεμένο κοινωνικό λογαριασμό.', 'users_social_connect' => 'Σύνδεση λογαριασμού', 'users_social_disconnect' => 'Αποσύνδεση λογαριασμού', - 'users_social_status_connected' => 'Connected', - 'users_social_status_disconnected' => 'Disconnected', + 'users_social_status_connected' => 'Συνδεδεμένο', + 'users_social_status_disconnected' => 'Αποσυνδεδεμένο', 'users_social_connected' => ':socialΛογαριασμός λογαριασμού συνδέθηκε με επιτυχία στο προφίλ σας.', 'users_social_disconnected' => ':socialΛογαριασμός αποσυνδέθηκε επιτυχώς από το προφίλ σας.', 'users_api_tokens' => 'API Tokens', - 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_desc' => 'Δημιουργία και διαχείριση των διακριτικών πρόσβασης (token) που χρησιμοποιούνται για τον έλεγχο ταυτότητας με το REST API του BookStack. Τα δικαιώματα για το API τα διαχειρίζεται ο χρήστης στον οποίο ανήκει το διακριτικό (token).', 'users_api_tokens_none' => 'Δεν έχουν δημιουργηθεί διακριτικά API για αυτόν το χρήστη', 'users_api_tokens_create' => 'Δημιουργία διακριτικού Api Token', 'users_api_tokens_expires' => 'Λήγει', @@ -277,13 +278,13 @@ return [ 'webhooks_last_error_message' => 'Τελευταίο μήνυμα λάθους:', // Licensing - 'licenses' => 'Licenses', - 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', - 'licenses_bookstack' => 'BookStack License', - 'licenses_php' => 'PHP Library Licenses', - 'licenses_js' => 'JavaScript Library Licenses', - 'licenses_other' => 'Other Licenses', - 'license_details' => 'License Details', + 'licenses' => 'Άδειες', + 'licenses_desc' => 'Αυτή η σελίδα αναφέρει λεπτομερώς τις πληροφορίες άδειας χρήσης για το BookStack επιπρόσθετα στα έργα και τις βιβλιοθήκες που χρησιμοποιούνται εντός του BookStack. Πολλά έργα που αναφέρονται μπορούν να χρησιμοποιηθούν μόνο σε ένα πλαίσιο ανάπτυξης.', + 'licenses_bookstack' => 'Άδεια BookStack', + 'licenses_php' => 'Άδειες Βιβλιοθήκης PHP', + 'licenses_js' => 'Άδειες Βιβλιοθήκης JavaScript', + 'licenses_other' => 'Άλλες άδειες', + 'license_details' => 'Λεπτομέρειες άδειας', //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. diff --git a/lang/el/validation.php b/lang/el/validation.php index be96cd0ca..12d4919ca 100644 --- a/lang/el/validation.php +++ b/lang/el/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Η μορφή του :attribute δεν είναι έγκυρη.', 'uploaded' => 'Δεν ήταν δυνατή η αποστολή του αρχείου. Ο διακομιστής ενδέχεται να μην δέχεται αρχεία αυτού του μεγέθους.', + 'zip_file' => 'Το :attribute πρέπει να παραπέμπει σε ένα αρχείο εντός του ZIP.', + 'zip_file_mime' => 'Το :attribute πρέπει να αναφέρεται σε αρχείο τύπου :validTypes, βρέθηκε :foundType.', + 'zip_model_expected' => 'Αναμενόταν αντικείμενο δεδομένων, αλλά ":type" βρέθηκε.', + 'zip_unique' => 'Το :attribute πρέπει να είναι μοναδικό για τον τύπο αντικειμένου εντός του ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/es/activities.php b/lang/es/activities.php index 9431eeb9f..5bd3fa5f1 100644 --- a/lang/es/activities.php +++ b/lang/es/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhook eliminado', 'webhook_delete_notification' => 'Webhook eliminado correctamente', + // Imports + 'import_create' => 'importación creada', + 'import_create_notification' => 'Importación cargada correctamente', + 'import_run' => 'importación actualizada', + 'import_run_notification' => 'Contenido importado correctamente', + 'import_delete' => 'importación borrada', + 'import_delete_notification' => 'Importación borrada correctamente', + // Users 'user_create' => 'usuario creado', 'user_create_notification' => 'Usuario creado correctamente', diff --git a/lang/es/editor.php b/lang/es/editor.php index 1f9f663fa..3d9e90c13 100644 --- a/lang/es/editor.php +++ b/lang/es/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Acerca del editor', 'about_title' => 'Acerca del editor WYSIWYG', 'editor_license' => 'Licencia del editor y derechos de autor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor se construye usando :tinyLink que se proporciona bajo la licencia MIT.', 'editor_tiny_license_link' => 'Aquí encontrará los detalles de los derechos de autor y la licencia de TinyMCE.', 'save_continue' => 'Guardar Página y Continuar', diff --git a/lang/es/entities.php b/lang/es/entities.php index d197c2098..733be165d 100644 --- a/lang/es/entities.php +++ b/lang/es/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Archivo PDF', 'export_text' => 'Archivo de texto', 'export_md' => 'Archivo Markdown', + 'export_zip' => 'ZIP portable', 'default_template' => 'Plantilla de página por defecto', 'default_template_explain' => 'Asigne una plantilla de página que se utilizará como contenido predeterminado para todas las páginas creadas en este elemento. Tenga en cuenta que esto sólo se utilizará si el creador de páginas tiene acceso a la plantilla de página elegida.', 'default_template_select' => 'Seleccione una página de plantilla', + 'import' => 'Importar', + 'import_validate' => 'Validar importación', + 'import_desc' => 'Importar libros, capítulos y páginas usando una exportación zip portable de la misma o distinta instancia. Seleccione un archivo ZIP para continuar. Después de que el archivo haya sido subido y validado, podrá configurar y confirmar la importación en la siguiente vista.', + 'import_zip_select' => 'Seleccione archivo ZIP a subir', + 'import_zip_validation_errors' => 'Se detectaron errores al validar el archivo ZIP proporcionado:', + 'import_pending' => 'Importaciones pendientes', + 'import_pending_none' => 'No se han iniciado importaciones.', + 'import_continue' => 'Continuar importación', + 'import_continue_desc' => 'Revise el contenido que debe importarse del archivo ZIP subido. Cuando esté listo, ejecute la importación para añadir su contenido a este sistema. El archivo de importación ZIP subido se eliminará automáticamente al terminar la importación correctamente.', + 'import_details' => 'Detalles de la Importación', + 'import_run' => 'Ejecutar Importación', + 'import_size' => ':size tamaño archivo ZIP', + 'import_uploaded_at' => 'Subido :relativeTime', + 'import_uploaded_by' => 'Subido por', + 'import_location' => 'Ubicación de Importación', + 'import_location_desc' => 'Seleccione una ubicación de destino para el contenido importado. Necesitará los permisos pertinentes para crearlo dentro de la ubicación que elija.', + 'import_delete_confirm' => '¿Está seguro de que desea eliminar esta importación?', + 'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.', + 'import_errors' => 'Errores de Importación', + 'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/es/errors.php b/lang/es/errors.php index deb6c1bc5..98aa2acd5 100644 --- a/lang/es/errors.php +++ b/lang/es/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => 'La aplicación :appName se encuentra caída en este momento', 'back_soon' => 'Volverá a estar operativa pronto.', + // Import + 'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.', + 'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.', + 'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.', + 'import_validation_failed' => 'Error al validar la importación del ZIP con errores:', + 'import_zip_failed_notification' => 'Error al importar archivo ZIP.', + 'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.', + 'import_perms_chapters' => 'Le faltan los permisos necesarios para crear capítulos.', + 'import_perms_pages' => 'Le faltan los permisos necesarios para crear páginas.', + 'import_perms_images' => 'Le faltan los permisos necesarios para crear imágenes.', + 'import_perms_attachments' => 'Le faltan los permisos necesarios para crear adjuntos.', + // API errors 'api_no_authorization_found' => 'No se encontró ningún token de autorización en la solicitud', 'api_bad_authorization_format' => 'Se ha encontrado un token de autorización en la solicitud pero el formato era incorrecto', diff --git a/lang/es/settings.php b/lang/es/settings.php index 62919fe4a..cb13cad12 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'API de sistema de acceso', 'role_manage_settings' => 'Gestionar ajustes de la aplicación', 'role_export_content' => 'Exportar contenido', + 'role_import_content' => 'Importar contenido', 'role_editor_change' => 'Cambiar editor de página', 'role_notifications' => 'Recibir y gestionar notificaciones', 'role_asset' => 'Permisos de contenido', diff --git a/lang/es/validation.php b/lang/es/validation.php index bfcb050be..d5f4f8495 100644 --- a/lang/es/validation.php +++ b/lang/es/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'El atributo :attribute tiene un formato inválido.', 'uploaded' => 'El archivo no ha podido subirse. Es posible que el servidor no acepte archivos de este tamaño.', + 'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.', + 'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.', + 'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".', + 'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/es_AR/activities.php b/lang/es_AR/activities.php index 62d986cea..f7e979493 100644 --- a/lang/es_AR/activities.php +++ b/lang/es_AR/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhook eliminado', 'webhook_delete_notification' => 'Webhook eliminado correctamente', + // Imports + 'import_create' => 'importación creada', + 'import_create_notification' => 'Importación cargada correctamente', + 'import_run' => 'importación actualizada', + 'import_run_notification' => 'Contenido importado correctamente', + 'import_delete' => 'importación borrada', + 'import_delete_notification' => 'Importación borrada correctamente', + // Users 'user_create' => 'usuario creado', 'user_create_notification' => 'Usuario creado correctamente', diff --git a/lang/es_AR/editor.php b/lang/es_AR/editor.php index 3cd7d4657..9002dc72b 100644 --- a/lang/es_AR/editor.php +++ b/lang/es_AR/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Acerca del editor', 'about_title' => 'Acerca del editor WYSIWYG', 'editor_license' => 'Licencia del editor y derechos de autor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor se construye usando :tinyLink provisto bajo la licencia MIT.', 'editor_tiny_license_link' => 'Aquí se muestran los detalles de los derechos de autor y la licencia de TinyMCE.', 'save_continue' => 'Guardar Página y Continuar', diff --git a/lang/es_AR/entities.php b/lang/es_AR/entities.php index d35682d2f..a118e16be 100644 --- a/lang/es_AR/entities.php +++ b/lang/es_AR/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Archivo PDF', 'export_text' => 'Archivo de texto plano', 'export_md' => 'Archivo Markdown', + 'export_zip' => 'ZIP portable', 'default_template' => 'Plantilla de página por defecto', 'default_template_explain' => 'Asigne una plantilla de página que se utilizará como contenido predeterminado para todas las páginas creadas en este elemento. Tenga en cuenta que esto sólo se utilizará si el creador de páginas tiene acceso a la plantilla de página elegida.', 'default_template_select' => 'Seleccione una página de plantilla', + 'import' => 'Importar', + 'import_validate' => 'Validar importación', + 'import_desc' => 'Importar libros, capítulos y páginas usando una exportación zip portable de la misma o distinta instancia. Seleccione un archivo ZIP para continuar. Después de que el archivo haya sido subido y validado, podrá configurar y confirmar la importación en la siguiente vista.', + 'import_zip_select' => 'Seleccione archivo ZIP a subir', + 'import_zip_validation_errors' => 'Se detectaron errores al validar el archivo ZIP proporcionado:', + 'import_pending' => 'Importaciones pendientes', + 'import_pending_none' => 'No se han iniciado importaciones.', + 'import_continue' => 'Continuar importación', + 'import_continue_desc' => 'Revise el contenido que debe importarse del archivo ZIP subido. Cuando esté listo, ejecute la importación para añadir su contenido a este sistema. El archivo de importación ZIP subido se eliminará automáticamente al terminar la importación correctamente.', + 'import_details' => 'Detalles de la Importación', + 'import_run' => 'Ejecutar Importación', + 'import_size' => ':size tamaño archivo ZIP', + 'import_uploaded_at' => 'Subido :relativeTime', + 'import_uploaded_by' => 'Subido por', + 'import_location' => 'Ubicación de Importación', + 'import_location_desc' => 'Seleccione una ubicación de destino para el contenido importado. Necesitará los permisos pertinentes para crearlo dentro de la ubicación que elija.', + 'import_delete_confirm' => '¿Está seguro de que desea eliminar esta importación?', + 'import_delete_desc' => 'Esto eliminará el archivo ZIP de importación subido y no se puede deshacer.', + 'import_errors' => 'Errores de Importación', + 'import_errors_desc' => 'Se han producido los siguientes errores durante el intento de importación:', // Permissions and restrictions 'permissions' => 'Permisos', diff --git a/lang/es_AR/errors.php b/lang/es_AR/errors.php index c25c793d0..cb1bc1202 100644 --- a/lang/es_AR/errors.php +++ b/lang/es_AR/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => 'La aplicación :appName se encuentra caída en este momento', 'back_soon' => 'Volverá a estar operativa en corto tiempo.', + // Import + 'import_zip_cant_read' => 'No se pudo leer el archivo ZIP.', + 'import_zip_cant_decode_data' => 'No se pudo encontrar y decodificar el archivo data.json. en el archivo ZIP.', + 'import_zip_no_data' => 'Los datos del archivo ZIP no contienen ningún libro, capítulo o contenido de página.', + 'import_validation_failed' => 'Error al validar la importación del ZIP con errores:', + 'import_zip_failed_notification' => 'Error al importar archivo ZIP.', + 'import_perms_books' => 'Le faltan los permisos necesarios para crear libros.', + 'import_perms_chapters' => 'Le faltan los permisos necesarios para crear capítulos.', + 'import_perms_pages' => 'Le faltan los permisos necesarios para crear páginas.', + 'import_perms_images' => 'Le faltan los permisos necesarios para crear imágenes.', + 'import_perms_attachments' => 'Le faltan los permisos necesarios para crear adjuntos.', + // API errors 'api_no_authorization_found' => 'No se encontró ningún token de autorización en la solicitud', 'api_bad_authorization_format' => 'Se ha encontrado un token de autorización en la solicitud pero el formato era incorrecto', diff --git a/lang/es_AR/settings.php b/lang/es_AR/settings.php index 84ed47694..9a78c8760 100644 --- a/lang/es_AR/settings.php +++ b/lang/es_AR/settings.php @@ -163,6 +163,7 @@ return [ 'role_access_api' => 'API de sistema de acceso', 'role_manage_settings' => 'Gestionar ajustes de activos', 'role_export_content' => 'Exportar contenido', + 'role_import_content' => 'Importar contenido', 'role_editor_change' => 'Cambiar editor de página', 'role_notifications' => 'Recibir y gestionar notificaciones', 'role_asset' => 'Permisos de activos', diff --git a/lang/es_AR/validation.php b/lang/es_AR/validation.php index 9b2b1b413..db16845e0 100644 --- a/lang/es_AR/validation.php +++ b/lang/es_AR/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'El atributo :attribute tiene un formato inválido.', 'uploaded' => 'El archivo no se pudo subir. Puede ser que el servidor no acepte archivos de este tamaño.', + 'zip_file' => 'El :attribute necesita hacer referencia a un archivo dentro del ZIP.', + 'zip_file_mime' => 'El :attribute necesita hacer referencia a un archivo de tipo :validTypes, encontrado :foundType.', + 'zip_model_expected' => 'Se esperaba un objeto de datos, pero se encontró ":type".', + 'zip_unique' => 'El :attribute debe ser único para el tipo de objeto dentro del ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/et/activities.php b/lang/et/activities.php index 468125510..bbc976250 100644 --- a/lang/et/activities.php +++ b/lang/et/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'kustutas veebihaagi', 'webhook_delete_notification' => 'Veebihaak on kustutatud', + // 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' => 'lisas kasutaja', 'user_create_notification' => 'Kasutaja on lisatud', diff --git a/lang/et/editor.php b/lang/et/editor.php index 610be6398..77b051456 100644 --- a/lang/et/editor.php +++ b/lang/et/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Redaktori info', 'about_title' => 'Info WYSIWYG redaktori kohta', 'editor_license' => 'Redaktori litsents ja autoriõigused', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'See redaktor on loodud :tinyLink abil, mis on saadaval MIT litsentsi alusel.', 'editor_tiny_license_link' => 'TinyMCE autoriõigused ja litsents on saadaval siin.', 'save_continue' => 'Salvesta leht ja jätka', diff --git a/lang/et/entities.php b/lang/et/entities.php index dd875a6f2..7afe7005d 100644 --- a/lang/et/entities.php +++ b/lang/et/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF fail', 'export_text' => 'Tekstifail', 'export_md' => 'Markdown fail', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Vaikimisi lehe mall', 'default_template_explain' => 'Vali lehe mall, mida kasutatakse kõigi selle objekti sees loodud lehtede vaikimisi sisuna. Pea meeles, et seda kasutatakse ainult siis, kui lehe loojal on valitud malli vaatamise õigus.', 'default_template_select' => 'Vali mall', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Õigused', diff --git a/lang/et/errors.php b/lang/et/errors.php index 732c94529..4faf1d008 100644 --- a/lang/et/errors.php +++ b/lang/et/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName on hetkel maas', 'back_soon' => 'See on varsti tagasi.', + // 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.', + '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.', + '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' => 'Päringust ei leitud volitustunnust', 'api_bad_authorization_format' => 'Päringust leiti volitustunnus, aga see ei olnud korrektses formaadis', diff --git a/lang/et/settings.php b/lang/et/settings.php index aa860c050..6f29fcba1 100644 --- a/lang/et/settings.php +++ b/lang/et/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Süsteemi API ligipääs', 'role_manage_settings' => 'Rakenduse seadete haldamine', 'role_export_content' => 'Sisu eksport', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Lehe redaktori muutmine', 'role_notifications' => 'Võta vastu ja halda teavitusi', 'role_asset' => 'Sisu õigused', diff --git a/lang/et/validation.php b/lang/et/validation.php index 5f7f6f3aa..6a6eb661c 100644 --- a/lang/et/validation.php +++ b/lang/et/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute on vigases formaadis.', 'uploaded' => 'Faili üleslaadimine ebaõnnestus. Server ei pruugi sellise suurusega faile vastu võtta.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/eu/activities.php b/lang/eu/activities.php index 15e11c476..eeee084ab 100644 --- a/lang/eu/activities.php +++ b/lang/eu/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhook ezabatua', 'webhook_delete_notification' => 'Webhook egoki ezabatua', + // 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/eu/editor.php b/lang/eu/editor.php index 161cb3b27..34c739978 100644 --- a/lang/eu/editor.php +++ b/lang/eu/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/eu/entities.php b/lang/eu/entities.php index 758856120..10c5dba12 100644 --- a/lang/eu/entities.php +++ b/lang/eu/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF fitxategia', 'export_text' => 'Testu lauko fitxategiak', 'export_md' => 'Markdown fitxategia', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Baimenak', diff --git a/lang/eu/errors.php b/lang/eu/errors.php index 33f033a17..2a747e5f8 100644 --- a/lang/eu/errors.php +++ b/lang/eu/errors.php @@ -105,6 +105,18 @@ return [ '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.', + '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.', + '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', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/eu/settings.php b/lang/eu/settings.php index 1ee5843e8..80f1c87f4 100644 --- a/lang/eu/settings.php +++ b/lang/eu/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Sistemako APIra sarrera', 'role_manage_settings' => 'Kudeatu aplikazio ezarpenak', 'role_export_content' => 'Exportatu edukia', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Fitxategi baimenak', diff --git a/lang/eu/validation.php b/lang/eu/validation.php index cd7ba387d..f79dc852f 100644 --- a/lang/eu/validation.php +++ b/lang/eu/validation.php @@ -105,6 +105,11 @@ return [ '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_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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/fa/activities.php b/lang/fa/activities.php index adc380249..c17fe6988 100644 --- a/lang/fa/activities.php +++ b/lang/fa/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'حذف وب هوک', 'webhook_delete_notification' => 'وب هوک با موفقیت حذف شد', + // 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' => 'کاربر ایجاد شده', 'user_create_notification' => 'کاربر با موفقیت به ایجاد شد', diff --git a/lang/fa/editor.php b/lang/fa/editor.php index 89d163de0..9fbb948f5 100644 --- a/lang/fa/editor.php +++ b/lang/fa/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'درباره ویرایشگر', 'about_title' => 'درباره ویرایشگر WYSIWYG', 'editor_license' => 'مجوز و حق کپی رایت ویرایشگر', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'این ویرایشگر توسط :tinyLink و تحت مجوز MIT ساخته شده است.', 'editor_tiny_license_link' => 'جزئیات کپی رایت و مجوز TinyMCE را می توانید در اینجا پیدا کنید.', 'save_continue' => 'ذخیره صفحه و ادامه', diff --git a/lang/fa/entities.php b/lang/fa/entities.php index 7d173e61e..7578c21c0 100644 --- a/lang/fa/entities.php +++ b/lang/fa/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'فایل PDF', 'export_text' => 'پرونده متنی ساده', 'export_md' => 'راهنما مارک‌دون', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'مجوزها', diff --git a/lang/fa/errors.php b/lang/fa/errors.php index 8a54f9d37..c4bc6963a 100644 --- a/lang/fa/errors.php +++ b/lang/fa/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName در حال حاضر قطع است', 'back_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.', + '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.', + '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' => 'هیچ نشانه مجوزی در درخواست یافت نشد', 'api_bad_authorization_format' => 'یک نشانه مجوز در این درخواست یافت شد اما قالب نادرست به نظر می‌رسید', diff --git a/lang/fa/settings.php b/lang/fa/settings.php index 4993c4671..bb18364f0 100644 --- a/lang/fa/settings.php +++ b/lang/fa/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'دسترسی به API سیستم', 'role_manage_settings' => 'تنظیمات برنامه را مدیریت کنید', 'role_export_content' => 'صادرات محتوا', + 'role_import_content' => 'Import content', 'role_editor_change' => 'تغییر ویرایشگر صفحه', 'role_notifications' => 'دریافت و مدیریت اعلان‌ها', 'role_asset' => 'مجوزهای دارایی', diff --git a/lang/fa/validation.php b/lang/fa/validation.php index 467c68702..10432471f 100644 --- a/lang/fa/validation.php +++ b/lang/fa/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute معتبر نمی‌باشد.', 'uploaded' => 'بارگذاری فایل :attribute موفقیت آمیز نبود.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/fi/activities.php b/lang/fi/activities.php index 6bb267c70..40588abcd 100644 --- a/lang/fi/activities.php +++ b/lang/fi/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'poisti toimintokutsun', 'webhook_delete_notification' => 'Toimintokutsu poistettiin onnistuneesti', + // 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' => 'loi käyttäjän', 'user_create_notification' => 'Käyttäjä luotiin onnistuneesti', diff --git a/lang/fi/editor.php b/lang/fi/editor.php index 34150e647..c2ab2d809 100644 --- a/lang/fi/editor.php +++ b/lang/fi/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Tietoja editorista', 'about_title' => 'Tietoja WYSIWYG-editorista', 'editor_license' => 'Editorin lisenssi ja tekijänoikeus', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Tämä editori on rakennettu käyttäen sovellusta :tinyLink, joka on MIT-lisenssin alainen.', 'editor_tiny_license_link' => 'TinyMCE-editorin tekijänoikeus- ja lisenssitiedot löytyvät täältä.', 'save_continue' => 'Tallenna sivu ja jatka', diff --git a/lang/fi/entities.php b/lang/fi/entities.php index c10886fe0..8743c9725 100644 --- a/lang/fi/entities.php +++ b/lang/fi/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF-tiedosto', 'export_text' => 'Tekstitiedosto', 'export_md' => 'Markdown-tiedosto', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Käyttöoikeudet', diff --git a/lang/fi/errors.php b/lang/fi/errors.php index 4ffb37e8a..d948b556b 100644 --- a/lang/fi/errors.php +++ b/lang/fi/errors.php @@ -106,6 +106,18 @@ Sovellus ei tunnista ulkoisen todennuspalvelun pyyntöä. Ongelman voi aiheuttaa 'app_down' => ':appName on kaatunut', 'back_soon' => 'Se palautetaan pian.', + // 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.', + '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.', + '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' => 'Pyynnöstä ei löytynyt valtuutuskoodia', 'api_bad_authorization_format' => 'Pyynnöstä löytyi valtuutuskoodi, mutta sen muoto oli virheellinen', diff --git a/lang/fi/settings.php b/lang/fi/settings.php index 3365d87bc..5d953dc70 100644 --- a/lang/fi/settings.php +++ b/lang/fi/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Pääsy järjestelmän ohjelmointirajapintaan', 'role_manage_settings' => 'Hallinnoi sivuston asetuksia', 'role_export_content' => 'Vie sisältöjä', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Vaihda sivun editoria', 'role_notifications' => 'Vastaanota ja hallinnoi ilmoituksia', 'role_asset' => 'Sisältöjen oikeudet', diff --git a/lang/fi/validation.php b/lang/fi/validation.php index 35c5f1924..0a9330433 100644 --- a/lang/fi/validation.php +++ b/lang/fi/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute muoto ei ole kelvollinen.', 'uploaded' => 'Tiedostoa ei voitu ladata. Palvelin ei ehkä hyväksy tämän kokoisia tiedostoja.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/fr/activities.php b/lang/fr/activities.php index 7acf5931a..871b88648 100644 --- a/lang/fr/activities.php +++ b/lang/fr/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'supprimer un Webhook', 'webhook_delete_notification' => 'Webhook supprimé avec succès', + // 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' => 'utilisateur créé', 'user_create_notification' => 'Utilisateur créé avec succès', diff --git a/lang/fr/editor.php b/lang/fr/editor.php index 558dda4c6..11edae10c 100644 --- a/lang/fr/editor.php +++ b/lang/fr/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'À propos de l\'éditeur', 'about_title' => 'À propos de l\'éditeur WYSIWYG', 'editor_license' => 'Licence d\'éditeur et droit d\'auteur', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Cet éditeur est construit en utilisant :tinyLink qui est fourni sous la licence MIT.', 'editor_tiny_license_link' => 'Vous trouverez ici les détails sur les droits d\'auteur et les licences de TinyMCE.', 'save_continue' => 'Enregistrer et continuer', diff --git a/lang/fr/entities.php b/lang/fr/entities.php index b92ddac0a..6581587d4 100644 --- a/lang/fr/entities.php +++ b/lang/fr/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Fichier PDF', 'export_text' => 'Document texte', 'export_md' => 'Fichiers Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Modèle de page par défaut', 'default_template_explain' => 'Sélectionnez un modèle de page qui sera utilisé comme contenu par défaut pour les nouvelles pages créées dans cet élément. Gardez à l\'esprit que le modèle ne sera utilisé que si le créateur de la page a accès au modèle sélectionné.', 'default_template_select' => 'Sélectionnez un modèle de 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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Autorisations', diff --git a/lang/fr/errors.php b/lang/fr/errors.php index 7658fd8a9..d89926dac 100644 --- a/lang/fr/errors.php +++ b/lang/fr/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName n\'est pas en service pour le moment', 'back_soon' => 'Nous serons bientôt de retour.', + // 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.', + '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.', + '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' => 'Aucun jeton d\'autorisation trouvé pour la demande', 'api_bad_authorization_format' => 'Un jeton d\'autorisation a été trouvé pour la requête, mais le format semble incorrect', diff --git a/lang/fr/settings.php b/lang/fr/settings.php index eb8ca396c..d113e89ab 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Accès à l\'API du système', 'role_manage_settings' => 'Gérer les préférences de l\'application', 'role_export_content' => 'Exporter le contenu', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Changer l\'éditeur de page', 'role_notifications' => 'Recevoir et gérer les notifications', 'role_asset' => 'Permissions des ressources', diff --git a/lang/fr/validation.php b/lang/fr/validation.php index 702c02585..ed16776e7 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute a un format invalide.', 'uploaded' => 'Le fichier n\'a pas pu être envoyé. Le serveur peut ne pas accepter des fichiers de cette taille.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/he/activities.php b/lang/he/activities.php index 75ab9b3ea..1954121f8 100644 --- a/lang/he/activities.php +++ b/lang/he/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'Webhook נמחק', 'webhook_delete_notification' => 'Webook נמחק בהצלחה', + // 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' => 'משתמש חדש נוצר', 'user_create_notification' => 'משתמש נוצר בהצלחה', diff --git a/lang/he/editor.php b/lang/he/editor.php index e37862a4b..a670385f9 100644 --- a/lang/he/editor.php +++ b/lang/he/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'אודות עורך הטקסט הויזואלי', 'editor_license' => 'רשיון וזכויות העורך', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'שמור עמוד והמשך', diff --git a/lang/he/entities.php b/lang/he/entities.php index 0dc6f882e..9ca53fa13 100644 --- a/lang/he/entities.php +++ b/lang/he/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'קובץ PDF', 'export_text' => 'טקסט רגיל', 'export_md' => 'קובץ Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'הרשאות', diff --git a/lang/he/errors.php b/lang/he/errors.php index b11375dae..75f7949ea 100644 --- a/lang/he/errors.php +++ b/lang/he/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName כרגע אינו זמין', 'back_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.', + '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.', + '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', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/he/settings.php b/lang/he/settings.php index bf585287c..9e63a43cd 100644 --- a/lang/he/settings.php +++ b/lang/he/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'גש ל-API המערכת', 'role_manage_settings' => 'ניהול הגדרות יישום', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'הרשאות משאבים', diff --git a/lang/he/validation.php b/lang/he/validation.php index 85f6c698e..d7c126783 100644 --- a/lang/he/validation.php +++ b/lang/he/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'שדה :attribute בעל פורמט שאינו תקין.', 'uploaded' => 'שדה :attribute ארעה שגיאה בעת ההעלאה.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/hr/activities.php b/lang/hr/activities.php index 284121b2d..f5853dc65 100644 --- a/lang/hr/activities.php +++ b/lang/hr/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'web- dojavnik izbrisan', 'webhook_delete_notification' => 'Web-dojavnik je uspješno izbrisan', + // 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' => 'kreirani korisnik', 'user_create_notification' => 'Korisnik je uspješno kreiran', diff --git a/lang/hr/editor.php b/lang/hr/editor.php index f3fb21ad4..55b38ec2d 100644 --- a/lang/hr/editor.php +++ b/lang/hr/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'O Editoru', 'about_title' => 'O WYSIWYG Editoru', 'editor_license' => 'Licenca i autorsko pravo uređivača', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ovaj uređivač je izrađen pomoću: tinyLink koji je dostupan pod MIT licencom.', 'editor_tiny_license_link' => 'Detalji o autorskim pravima i licenci za TinyMCE mogu se pronaći ovdje.', 'save_continue' => 'Spremi Stranicu i Nastavi', diff --git a/lang/hr/entities.php b/lang/hr/entities.php index 01ceb054b..91621ed8c 100644 --- a/lang/hr/entities.php +++ b/lang/hr/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF Datoteka', 'export_text' => 'Text File', 'export_md' => 'Markdown Datoteka', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Dopuštenja', diff --git a/lang/hr/errors.php b/lang/hr/errors.php index 31577863c..92b9de7d2 100644 --- a/lang/hr/errors.php +++ b/lang/hr/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName trenutno nije dostupna', 'back_soon' => 'Uskoro će se vratiti.', + // 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.', + '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.', + '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' => 'Nije pronađena autorizacija', 'api_bad_authorization_format' => 'Pogreška prilikom autorizacije', diff --git a/lang/hr/settings.php b/lang/hr/settings.php index e58c3a6d4..a8b8fea63 100644 --- a/lang/hr/settings.php +++ b/lang/hr/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'API pristup', 'role_manage_settings' => 'Upravljanje postavkama aplikacija', 'role_export_content' => 'Izvoz sadržaja', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Promijeni uređivač stranica', 'role_notifications' => 'Primanje i upravljanje obavijestima', 'role_asset' => 'Upravljanje vlasništvom', diff --git a/lang/hr/validation.php b/lang/hr/validation.php index 95263ff07..32b11a9bd 100644 --- a/lang/hr/validation.php +++ b/lang/hr/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Format :attribute nije valjan.', 'uploaded' => 'Datoteka se ne može prenijeti. Server možda ne prihvaća datoteke te veličine.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/hu/activities.php b/lang/hu/activities.php index ea3d0cef3..b0dbfd45e 100644 --- a/lang/hu/activities.php +++ b/lang/hu/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhookot törölt', 'webhook_delete_notification' => 'Webhook sikeresen törölve', + // 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' => 'felhasználó létrehozása', 'user_create_notification' => 'Felhasználó sikeresen létrehozva', diff --git a/lang/hu/editor.php b/lang/hu/editor.php index b71efec20..339ece151 100644 --- a/lang/hu/editor.php +++ b/lang/hu/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'A szerkesztőről', 'about_title' => 'A WYSIWYG szerkesztőről', 'editor_license' => 'Szerkesztő Licensz és Copyright információi', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ez a szerkesztő az MIT licenc alatt szolgáltatott :tinyLink segítségével készült.', 'editor_tiny_license_link' => 'A TinyMCE szerzői jogi és licencinformációi itt találhatók.', 'save_continue' => 'Mentés és Folytatás', diff --git a/lang/hu/entities.php b/lang/hu/entities.php index 174197827..05c863878 100644 --- a/lang/hu/entities.php +++ b/lang/hu/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF fájl', 'export_text' => 'Egyszerű szövegfájl', 'export_md' => 'Markdown jegyzetek', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Alapértelmezett oldalsablon', 'default_template_explain' => 'Rendeljen hozzá egy oldalsablont, amely alapértelmezett tartalomként lesz használva az ezen az elemen belül létrehozott összes oldalon. Ne feledje, hogy ezt csak akkor használja, ha az oldal készítője megtekintési hozzáféréssel rendelkezik a kiválasztott sablonoldalhoz.', 'default_template_select' => 'Válasszon ki egy oldalsablont', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Jogosultságok', diff --git a/lang/hu/errors.php b/lang/hu/errors.php index 275eb65fe..00f47ac40 100644 --- a/lang/hu/errors.php +++ b/lang/hu/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName jelenleg nem üzemel', 'back_soon' => 'Hamarosan újra elérhető lesz.', + // 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.', + '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.', + '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' => 'A kérésben nem található hitelesítési vezérjel', 'api_bad_authorization_format' => 'A kérésben hitelesítési vezérjel található de a formátuma érvénytelennek tűnik', diff --git a/lang/hu/settings.php b/lang/hu/settings.php index 7398de9e1..55471ee0f 100644 --- a/lang/hu/settings.php +++ b/lang/hu/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Hozzáférés a rendszer API-hoz', 'role_manage_settings' => 'Alkalmazás beállításainak kezelése', 'role_export_content' => 'Tartalom exportálása', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Oldalszerkesztő módosítása', 'role_notifications' => 'Értesítések fogadása és kezelése', 'role_asset' => 'Eszköz jogosultságok', diff --git a/lang/hu/validation.php b/lang/hu/validation.php index 863e39d53..a215416ca 100644 --- a/lang/hu/validation.php +++ b/lang/hu/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute formátuma érvénytelen.', 'uploaded' => 'A fájlt nem lehet feltölteni. A kiszolgáló nem fogad el ilyen méretű fájlokat.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/id/activities.php b/lang/id/activities.php index bc20b86ff..8c3e800bb 100644 --- a/lang/id/activities.php +++ b/lang/id/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'menghapus webhook', 'webhook_delete_notification' => 'Webhook berhasil dihapus', + // 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' => 'pengguna yang dibuat', 'user_create_notification' => 'Pengguna berhasil dibuat', diff --git a/lang/id/editor.php b/lang/id/editor.php index 73a374a1d..c0b72601a 100644 --- a/lang/id/editor.php +++ b/lang/id/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/id/entities.php b/lang/id/entities.php index cf87ab025..81d6763d0 100644 --- a/lang/id/entities.php +++ b/lang/id/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Dokumen PDF', 'export_text' => 'Dokumen Teks Biasa', 'export_md' => 'File Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Izin', diff --git a/lang/id/errors.php b/lang/id/errors.php index 8a4bc658f..b4e2c6feb 100644 --- a/lang/id/errors.php +++ b/lang/id/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName sedang down sekarang', 'back_soon' => 'Ini akan segera kembali.', + // 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.', + '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.', + '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' => 'Tidak ada token otorisasi yang ditemukan pada permintaan tersebut', 'api_bad_authorization_format' => 'Token otorisasi ditemukan pada permintaan tetapi formatnya salah', diff --git a/lang/id/settings.php b/lang/id/settings.php index 21979ba60..abc4c86b4 100644 --- a/lang/id/settings.php +++ b/lang/id/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Akses Sistem API', 'role_manage_settings' => 'Kelola setelan aplikasi', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Izin Aset', diff --git a/lang/id/validation.php b/lang/id/validation.php index e1fcbc724..d922113d9 100644 --- a/lang/id/validation.php +++ b/lang/id/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute format tidak valid.', 'uploaded' => 'Berkas tidak dapat diunggah. Server mungkin tidak menerima berkas dengan ukuran ini.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/is/activities.php b/lang/is/activities.php new file mode 100644 index 000000000..5c84cdb53 --- /dev/null +++ b/lang/is/activities.php @@ -0,0 +1,132 @@ + 'stofna síðu', + 'page_create_notification' => 'Síða stofnuð', + 'page_update' => 'nafnlaus síða', + 'page_update_notification' => 'Síða uppfærð', + 'page_delete' => 'síðu eytt', + 'page_delete_notification' => 'Tókst að eyða síðu', + 'page_restore' => 'endurvirkja síðu', + 'page_restore_notification' => 'Síða endurvirkjuð', + 'page_move' => 'síða færð', + 'page_move_notification' => 'Tókst að færa síðu', + + // Chapters + 'chapter_create' => 'kafli búinn til', + 'chapter_create_notification' => 'Tókst að búa til kafla', + 'chapter_update' => 'kafli uppfærður', + 'chapter_update_notification' => 'Tókst að uppfæra kafla', + 'chapter_delete' => 'eyddur kafli', + 'chapter_delete_notification' => 'Tókst að eyða kafla', + 'chapter_move' => 'færður kafli', + 'chapter_move_notification' => 'Tókst að færa kafla', + + // Books + 'book_create' => 'stofnuð bók', + 'book_create_notification' => 'Tókst að stofna bók', + 'book_create_from_chapter' => 'kafla breytt í bók', + 'book_create_from_chapter_notification' => 'Tókst að breyta kafla í bók', + 'book_update' => 'uppfærð bók', + 'book_update_notification' => 'Tókst að uppfæra bók', + 'book_delete' => 'eydd bók', + 'book_delete_notification' => 'Tókst að eyða bók', + 'book_sort' => 'flokkuð bók', + 'book_sort_notification' => 'Tókst að endurflokka bók', + + // Bookshelves + 'bookshelf_create' => 'stofna hillu', + 'bookshelf_create_notification' => 'Tókst að stofna hillu', + 'bookshelf_create_from_book' => 'bók breytt i hillu', + 'bookshelf_create_from_book_notification' => 'Tókst að breyta bók í hillu', + 'bookshelf_update' => 'uppærð hilla', + 'bookshelf_update_notification' => 'Tókst að uppfæra hillu', + 'bookshelf_delete' => 'eydd hilla', + 'bookshelf_delete_notification' => 'Tókst að eyða hillu', + + // Revisions + 'revision_restore' => 'útgáfa bakfærð', + 'revision_delete' => 'útgáfu eytt', + 'revision_delete_notification' => 'Tókst að eyða útgáfu', + + // Favourites + 'favourite_add_notification' => 'hefur verið bætt í eftirlæti', + 'favourite_remove_notification' => 'hefur verið eytt úr eftirlæti', + + // Watching + 'watch_update_level_notification' => 'Fylgjast með hefur verið uppfært', + + // Auth + 'auth_login' => 'skráður inn', + 'auth_register' => 'skráður sem nýr notandi', + 'auth_password_reset_request' => 'bað um nýtt lykilorð', + 'auth_password_reset_update' => 'endurstilla lykilorð', + 'mfa_setup_method' => 'valin MFA aðferð', + 'mfa_setup_method_notification' => 'Fjölauðkenningar aðferð stillt', + 'mfa_remove_method' => 'fjarlægja MFA aðferð', + 'mfa_remove_method_notification' => 'Fjölauðkenningar aðferð fjarlægð', + + // Settings + 'settings_update' => 'uppfæra stillingar', + 'settings_update_notification' => 'Tókst að uppfæra stillingar', + 'maintenance_action_run' => 'keyrði uppfærslu', + + // Webhooks + 'webhook_create' => 'webhook búin til', + 'webhook_create_notification' => 'Tókst að búa til Webhook', + 'webhook_update' => 'webhook uppfærður', + 'webhook_update_notification' => 'Tókst að uppfæra Webhook', + 'webhook_delete' => 'eyða Webhook', + 'webhook_delete_notification' => 'Tókst að eyða Webhook', + + // Imports + 'import_create' => 'búa til innlestur', + 'import_create_notification' => 'Innlestur tókst', + 'import_run' => 'uppfæra innlestur', + 'import_run_notification' => 'Tókst að lesa inn', + 'import_delete' => 'innlestri eytt', + 'import_delete_notification' => 'Tókst að eyða innlestri', + + // Users + 'user_create' => 'stofnaður notandi', + 'user_create_notification' => 'Tókst að stofna notanda', + 'user_update' => 'uppfærður notandi', + 'user_update_notification' => 'Tókst að uppfæra notanda', + 'user_delete' => 'eyddur notandi', + 'user_delete_notification' => 'Tókst að eyða notanda', + + // API Tokens + 'api_token_create' => 'API token búið til', + 'api_token_create_notification' => 'Tókst að búa til API tóka', + 'api_token_update' => 'API tóki uppfærður', + 'api_token_update_notification' => 'Tókst að uppfæra API tóka', + 'api_token_delete' => 'eyddur API tóki', + 'api_token_delete_notification' => 'Tókst að eyða API tóka', + + // Roles + 'role_create' => 'stofnað hlutverk', + 'role_create_notification' => 'Tókst að stofna hlutverk', + 'role_update' => 'hlutverk uppfært', + 'role_update_notification' => 'Tókst að uppfæra hlutverk', + 'role_delete' => 'eytt hlutverk', + 'role_delete_notification' => 'Tókst að eyða hlutverki', + + // Recycle Bin + 'recycle_bin_empty' => 'tæmd ruslatunna', + 'recycle_bin_restore' => 'endurheimt úr ruslatunnu', + 'recycle_bin_destroy' => 'fjarlægt úr ruslatunnu', + + // Comments + 'commented_on' => 'athugasemd á', + 'comment_create' => 'athugasemd bætt við', + 'comment_update' => 'athugasemd uppfærð', + 'comment_delete' => 'athugasemd eytt', + + // Other + 'permissions_update' => 'uppfærðar heimildir', +]; diff --git a/lang/is/auth.php b/lang/is/auth.php new file mode 100644 index 000000000..c5e7ce4f1 --- /dev/null +++ b/lang/is/auth.php @@ -0,0 +1,117 @@ + 'Þeesi auðkenning er ekki á skrá.', + 'throttle' => 'Of margar tilraunir til innskráningar. Reyndu aftur eftir :seconds sekúndur.', + + // Login & Register + 'sign_up' => 'Nýskrá', + 'log_in' => 'Innskrá', + 'log_in_with' => 'Innskrá með :socialDriver', + 'sign_up_with' => 'Búa til aðgang með :socialDriver', + 'logout' => 'Skrá út', + + 'name' => 'Nafn', + 'username' => 'Notandanafn', + 'email' => 'Netfang', + 'password' => 'Lykilorð', + 'password_confirm' => 'Staðfestu lykilorð', + 'password_hint' => 'Verður að vera minnst 8 stafir', + 'forgot_password' => 'Gleymt lykilorð?', + 'remember_me' => 'Geyma innskráningarupplýsingar', + 'ldap_email_hint' => 'Settu inn netfang til að nota þennan aðgang.', + 'create_account' => 'Stofna aðgang', + 'already_have_account' => 'Þegar með notandaaðgang?', + 'dont_have_account' => 'Ekki með aðgang?', + 'social_login' => 'Innskráning með samfélagsmiðli', + 'social_registration' => 'Skráning samfélagsmiðils', + 'social_registration_text' => 'Skráðu þig og innskrá með annari þjónustu.', + + 'register_thanks' => 'Takk fyrir að skrá þig!', + 'register_confirm' => 'Skoðaðu tölvupóstinn þinn og smelltu á staðfestingarhlekkinn :appName.', + 'registrations_disabled' => 'Skráningar eru óvirkar í augnablikinu', + 'registration_email_domain_invalid' => 'Þetta lén hefur ekki aðgang að þessu forriti', + 'register_success' => 'Takk fyrir að skrá þig, nú ertu innskráðursem notandi.', + + // Login auto-initiation + 'auto_init_starting' => 'Reyni innskráningu', + 'auto_init_starting_desc' => 'Reyni að tengjast auðkenningarþjónustu, ef ekkert gerist innan 5 sekúndna getur þú smellt á hlekkinn hér að neðan.', + 'auto_init_start_link' => 'Halda áfram með auðkenningu', + + // Password Reset + 'reset_password' => 'Endurstilla lykilorð', + 'reset_password_send_instructions' => 'Settu netfangið þitt hér að neðan og þú færð tölvupóst með endurstillingar hlekk.', + 'reset_password_send_button' => 'Senda hlekk', + 'reset_password_sent' => 'Endurstillingar hlekkur hefur verið sendur í tölvupósti :email ef netfangið er á skrá.', + 'reset_password_success' => 'Lykilorðið þitt hefur verið endurstillt.', + 'email_reset_subject' => 'Endurstilla :appName lykilorðið þitt', + 'email_reset_text' => 'Þú fékkst þennan tölvupóst því að beðið var um endurstillingu lykilorðs á þínum aðgangi.', + 'email_reset_not_requested' => 'Ef þú baðst ekki um endurstillingu lykilorðs þarftu ekki að gera neitt.', + + // Email Confirmation + 'email_confirm_subject' => 'Staðfestu netfangið þitt á :appName', + 'email_confirm_greeting' => 'Takk fyrir að skrá þig á :appName!', + 'email_confirm_text' => 'Vinsamlegast staðfestu netfangið þitt með því að smella á hnappin hér fyrir neðan:', + 'email_confirm_action' => 'Staðfesta netfang', + 'email_confirm_send_error' => 'Staðfesting netfangs er nauðsynleg en kerfið gat ekki sent póst, vinsamlegast hafið samband við kerfisstjóra.', + 'email_confirm_success' => 'Netfang þitt hefur verið staðfest, þú ættir nú að geta skráð þig inn með þessu netfangi.', + 'email_confirm_resent' => 'Staðfestingar tölvupóstur hefur verið sendur, kíktu í póshólfið þitt.', + 'email_confirm_thanks' => 'Takk fyrir að staðfesta!', + 'email_confirm_thanks_desc' => 'Hinkraðu smá á meðan staðfestingin þín er í vinnslu, ef ekkert gerist eftir 3 sekúndur, smelltu á "Halda áfram" hlekkinn hér fyrir neðan.', + + 'email_not_confirmed' => 'Netfang hefur ekki verið staðfest', + 'email_not_confirmed_text' => 'Netfangið þitt hefur ekki enn verið staðfest.', + 'email_not_confirmed_click_link' => 'Vinsamlegast smelltu á hlekkinn sem barst þér í tölvupósti eftir skráningu.', + 'email_not_confirmed_resend' => 'Ef þú finnur ekki tölvupóstinn sem var sendur á þig, getur þú fengið hann endursendann með því að fylla út formið hér að neðan.', + 'email_not_confirmed_resend_button' => 'Endursenda staðfestingarpóst', + + // User Invite + 'user_invite_email_subject' => 'Þér hefur verið boðið að tengjast :appName!', + 'user_invite_email_greeting' => 'Það hefur verið stofnaður aðgangur fyrir ig á :appName.', + 'user_invite_email_text' => 'Smelltu á hnappinn fyrir neðan til að setja upp lykilorð og fá aðgang:', + 'user_invite_email_action' => 'Settu inn lykilorð', + 'user_invite_page_welcome' => 'Velkominn á :appName!', + 'user_invite_page_text' => 'Til að ljúka við uppsetningu og fá aðgang að :appName verður þú að velja þér lykilorð.', + 'user_invite_page_confirm_button' => 'Staðfestu lykilorð', + 'user_invite_success_login' => 'Lykilorð klárt, nú ættir þú að geta skráð þig inn á :appName!', + + // Multi-factor Authentication + 'mfa_setup' => 'Setja upp tvöfalda auðkenningu', + 'mfa_setup_desc' => 'Tvöföld euðkenning er viðbótar vörn til að tryggja aðganginn þinn.', + 'mfa_setup_configured' => 'Þegar uppsett', + 'mfa_setup_reconfigure' => 'Endurstilla', + 'mfa_setup_remove_confirmation' => 'Ertu viss um að þú viljið fjarlæga þessa auðkenningarleið?', + 'mfa_setup_action' => 'Uppsetning', + 'mfa_backup_codes_usage_limit_warning' => 'Þú átt færri en 5 tilraunir eftir. Búðu til og geymdu hjá þér fleiri tilraunir svo þú læsist ekki úti.', + 'mfa_option_totp_title' => 'App', + 'mfa_option_totp_desc' => 'Til að virkja tvöfalda auðkenningu verður þú að hafa app í símanum sem styður TOPT, til dæmis Google Authenticator, Authy eða Microsoft Authenticator.', + 'mfa_option_backup_codes_title' => 'Varakóðar', + 'mfa_option_backup_codes_desc' => 'Býr til sett af einskiptis kóðum sem þú getur notað til að auðkenna þig með. Geymdu þessa kóða á öruggum stað.', + 'mfa_gen_confirm_and_enable' => 'Staðfesta og virkja', + 'mfa_gen_backup_codes_title' => 'Stillingar varakóða', + 'mfa_gen_backup_codes_desc' => 'Geymdu listann af kóðum á öruggum stað. Þú getur notað þessa kóða sem auka auðkenningu.', + 'mfa_gen_backup_codes_download' => 'Hala niður kóðum', + 'mfa_gen_backup_codes_usage_warning' => 'Hver kóði getur bara verið notaður einu sinni', + 'mfa_gen_totp_title' => 'Uppsetning Apps', + 'mfa_gen_totp_desc' => 'Til að virkja tvöfalda auðkenningu verður þú að hafa app í símanum sem styður TOPT, til dæmis Google Authenticator, Authy eða Microsoft Authenticator.', + 'mfa_gen_totp_scan' => 'Skannaðu QR kóðann með appinu sem þú notar fyrir tvöfalda auðkenningu.', + 'mfa_gen_totp_verify_setup' => 'Staðfesta uppsetningu', + 'mfa_gen_totp_verify_setup_desc' => 'Staðfestu að allt virki með því að setja inn kóða úr síma appinu þínu hér fyrir neðan:', + 'mfa_gen_totp_provide_code_here' => 'Sláðu inn kóða úr auðkennningar appi', + 'mfa_verify_access' => 'Staðfesta aðgang', + 'mfa_verify_access_desc' => 'Aðgangurinn þinn þarf viðbótar auðkenningu, veldu auðkenningarleið.', + 'mfa_verify_no_methods' => 'Engar aðferðir stilltar', + 'mfa_verify_no_methods_desc' => 'Engin aukaauðkenningar aðferð fannst. Þú verður að setja upp minnst eina viðbótarauðkenningu til að halda áfram.', + 'mfa_verify_use_totp' => 'Staðfestu með farsíma appi', + 'mfa_verify_use_backup_codes' => 'Staðfesta með varakóða', + 'mfa_verify_backup_code' => 'Varakóði', + 'mfa_verify_backup_code_desc' => 'Settu inn einn af varakóðunum þínum hér að neðan:', + 'mfa_verify_backup_code_enter_here' => 'Sláðu inn varakóða hér', + 'mfa_verify_totp_desc' => 'Sláðu inn kóðann úr auðkenningar appinu úr símanum þínum:', + 'mfa_setup_login_notification' => 'Tvöföld auðkenning stillt. Skráðu þig nú inn með euðkenningarleiðinni.', +]; diff --git a/lang/is/common.php b/lang/is/common.php new file mode 100644 index 000000000..721d844ed --- /dev/null +++ b/lang/is/common.php @@ -0,0 +1,113 @@ + 'Hætta við', + 'close' => 'Loka', + 'confirm' => 'Staðfesta', + 'back' => 'Til baka', + 'save' => 'Vista', + 'continue' => 'Halda áfram', + 'select' => 'Velja', + 'toggle_all' => 'Velja allt', + 'more' => 'Meira', + + // Form Labels + 'name' => 'Nafn', + 'description' => 'Lýsing', + 'role' => 'Hlutverk', + 'cover_image' => 'Forsíðumynd', + 'cover_image_description' => 'Myndin ætti að fera u. þ. b 440x250px þótt hún verði sköluð og kroppuð eftir þörfum, þannig að endanleg stærð mun endurspegla það.', + + // Actions + 'actions' => 'Aðgerðir', + 'view' => 'Skoða', + 'view_all' => 'Skoða allt', + 'new' => 'Ný', + 'create' => 'Búa til', + 'update' => 'Uppfæra', + 'edit' => 'Breyta', + 'sort' => 'Flokka', + 'move' => 'Færa', + 'copy' => 'Afrita', + 'reply' => 'Svara', + 'delete' => 'Eyða', + 'delete_confirm' => 'Staðfesta eyðingu', + 'search' => 'Leita', + 'search_clear' => 'Hreinsa leit', + 'reset' => 'Endurstilla', + 'remove' => 'Fjarlægja', + 'add' => 'Bæta við', + 'configure' => 'Stilla', + 'manage' => 'Stýra', + 'fullscreen' => 'Fylla skjá', + 'favourite' => 'Eftirlæti', + 'unfavourite' => 'Fjarlægja úr eftirlæti', + 'next' => 'Næst', + 'previous' => 'Fyrri', + 'filter_active' => 'Virk sía:', + 'filter_clear' => 'Hreinsa síu', + 'download' => 'Hlaða niður', + 'open_in_tab' => 'Opna í flipa', + 'open' => 'Opna', + + // Sort Options + 'sort_options' => 'Valkostir röðunar', + 'sort_direction_toggle' => 'Flokkunarátt', + 'sort_ascending' => 'Raða vaxandi', + 'sort_descending' => 'Raða minnkandi', + 'sort_name' => 'Nafn', + 'sort_default' => 'Sjálfgefið', + 'sort_created_at' => 'Stofnað þann', + 'sort_updated_at' => 'Uppfært þann', + + // Misc + 'deleted_user' => 'Eyddur notandi', + 'no_activity' => 'Engin virkni til að sýna', + 'no_items' => 'Engir hlutir tiltækir', + 'back_to_top' => 'Fara efst', + 'skip_to_main_content' => 'Fara í aðalefni', + 'toggle_details' => 'Virkja nánari sýn', + 'toggle_thumbnails' => 'Sýna smámynd', + 'details' => 'Nánari upplýsingar', + 'grid_view' => 'Grid View', + 'list_view' => 'Lista sýn', + 'default' => 'Sjálfgefið', + 'breadcrumb' => 'Brauðmolar', + 'status' => 'Staða', + 'status_active' => 'Virkt', + 'status_inactive' => 'Óvirkt', + 'never' => 'Aldrei', + 'none' => 'Engin', + + // Header + 'homepage' => 'Forsíða', + 'header_menu_expand' => 'Leiðarstjórn', + 'profile_menu' => 'Prófíll', + 'view_profile' => 'Skoða prófíl', + 'edit_profile' => 'Breyta prófíl', + 'dark_mode' => 'Dimmsnið', + 'light_mode' => 'Ljóssnið', + 'global_search' => 'Heildarleit', + + // Layout tabs + 'tab_info' => 'Upplýsingar', + 'tab_info_label' => 'Tab: Sýna fleiri upplýsingar', + 'tab_content' => 'Innihald', + 'tab_content_label' => 'Tab: Sýna aðalinnihald', + + // Email Content + 'email_action_help' => 'Ef þú átt í vandræðum með að smella á ":actionText" hnappinn, afritaðu og límdu slóðina í vefskoðarann þinn:', + 'email_rights' => 'Höfundaréttur varinn', + + // Footer Link Options + // Not directly used but available for convenience to users. + 'privacy_policy' => 'Persónuverndarstefna', + 'terms_of_service' => 'Skilmálar þjónustu', + + // OpenSearch + 'opensearch_description' => 'Leita :appName', +]; diff --git a/lang/is/components.php b/lang/is/components.php new file mode 100644 index 000000000..6e0527eee --- /dev/null +++ b/lang/is/components.php @@ -0,0 +1,46 @@ + 'Myndaval', + 'image_list' => 'Myndalisti', + 'image_details' => 'Uplýsingar myndar', + 'image_upload' => 'Hlaða upp mynd', + 'image_intro' => 'Hér getur þú valið og stjórnað þeim myndum sem þegar hefur verið upphlaðið.', + 'image_intro_upload' => 'Hladdu upp nýrri mynd með því að draga hana inn í þennan glugga eða nota "Hlaða upp" hnappinn hér fyrir ofan.', + 'image_all' => 'Allar', + 'image_all_title' => 'Skoða allar myndir', + 'image_book_title' => 'Skoða þær myndir sem þegar hefur verið hlaðið upp í þessa bók', + 'image_page_title' => 'Skoða þær myndir sem þegar hefur verið hlaðið upp á þessa síðu', + 'image_search_hint' => 'Leita af myndum eftir nafni', + 'image_uploaded' => 'Hlaðið upp þann :uploadedDate', + 'image_uploaded_by' => 'Hlaðið upp af :userName', + 'image_uploaded_to' => 'Hlaðið upp á :pageLink', + 'image_updated' => 'Uppfært þann :updateDate', + 'image_load_more' => 'Hlaða fleirum', + 'image_image_name' => 'Nafn myndar', + 'image_delete_used' => 'Þessi mynd er notuð á eftirfarandi síðum.', + 'image_delete_confirm_text' => 'Ertu viss um að þú viljir eyða þessari mynd?', + 'image_select_image' => 'Velja mynd', + 'image_dropzone' => 'Dragðu myndir eða smelltu hér til að hlaða upp', + 'image_dropzone_drop' => 'Dragðu myndir hingað til að hlaða upp', + 'images_deleted' => 'Myndum eytt', + 'image_preview' => 'Forskoðun mynda', + 'image_upload_success' => 'Upphal myndar tókst', + 'image_update_success' => 'Upplýsingar um mynd uppfærðar', + 'image_delete_success' => 'Tókst að eyða mynd', + 'image_replace' => 'Skipta um mynd', + 'image_replace_success' => 'Tókst að skipta um skrá', + 'image_rebuild_thumbs' => 'Endurgera stærðastillingar', + 'image_rebuild_thumbs_success' => 'Tókst að endurgera stærðarstillingar!', + + // Code Editor + 'code_editor' => 'Breyta kóða', + 'code_language' => 'Tungumál kóða', + 'code_content' => 'Innihald kóða', + 'code_session_history' => 'Saga lotu', + 'code_save' => 'Vista kóða', +]; diff --git a/lang/is/editor.php b/lang/is/editor.php new file mode 100644 index 000000000..4e0ebcb44 --- /dev/null +++ b/lang/is/editor.php @@ -0,0 +1,179 @@ + 'Almennt', + 'advanced' => 'Ítarlegt', + 'none' => 'Engin', + 'cancel' => 'Hætta við', + 'save' => 'Vista', + 'close' => 'Loka', + 'undo' => 'Afturkalla', + 'redo' => 'Endurgera', + 'left' => 'Vinstri', + 'center' => 'Miðja', + 'right' => 'Hægri', + 'top' => 'Efst', + 'middle' => 'Miðja', + 'bottom' => 'Neðst', + 'width' => 'Breidd', + 'height' => 'Hæð', + 'More' => 'Meira', + 'select' => 'Velja...', + + // Toolbar + 'formats' => 'Snið', + 'header_large' => 'Stór fyrirsögn', + 'header_medium' => 'Miðlungs fyrirsögn', + 'header_small' => 'Lítil fyrirsögn', + 'header_tiny' => 'Örsmá fyrirsögn', + 'paragraph' => 'Málsgrein', + 'blockquote' => 'Gæsalappir', + 'inline_code' => 'Inline code', + 'callouts' => 'Vitna í', + 'callout_information' => 'Upplýsingar', + 'callout_success' => 'Árangur', + 'callout_warning' => 'Aðvörun', + 'callout_danger' => 'Hætta', + 'bold' => 'Feitletrað', + 'italic' => 'Skáletrað', + 'underline' => 'Undirstrikað', + 'strikethrough' => 'Yfirstrikað', + 'superscript' => 'Háletur', + 'subscript' => 'Lágletur', + 'text_color' => 'Litur texta', + 'custom_color' => 'Sérsniðinn litur', + 'remove_color' => 'Fjarlægja lit', + 'background_color' => 'Bakgrunnslitur', + 'align_left' => 'Jafna til vinstri', + 'align_center' => 'Miðju jafna', + 'align_right' => 'Hægrijafna', + 'align_justify' => 'Jafna', + 'list_bullet' => 'Punkta listi', + 'list_numbered' => 'Númeraður listi', + 'list_task' => 'Aðgerðar listi', + 'indent_increase' => 'Auka inndrátt', + 'indent_decrease' => 'Minnka inndrátt', + 'table' => 'Tafla', + 'insert_image' => 'Setja inn mynd', + 'insert_image_title' => 'Setja inn eða breyta mynd', + 'insert_link' => 'Setja inn eða breyta hlekk', + 'insert_link_title' => 'Setja inn eða breyta hlekk', + 'insert_horizontal_line' => 'Setja inn lárétta línu', + 'insert_code_block' => 'Setja inn kóðastubb', + 'edit_code_block' => 'Breyta kóðastubb', + 'insert_drawing' => 'Setja inn eða breyta teikningu', + 'drawing_manager' => 'Teikningastjóri', + 'insert_media' => 'Setja inn eða breyta miðlum', + 'insert_media_title' => 'Setja inn eða breyta miðlum', + 'clear_formatting' => 'Hreinsa forsnið', + 'source_code' => 'Frumkóði', + 'source_code_title' => 'Frumkóði', + 'fullscreen' => 'Fullann skjá', + 'image_options' => 'Myndastillingar', + + // Tables + 'table_properties' => 'Eiginleikar töflu', + 'table_properties_title' => 'Eiginleikar töflu', + 'delete_table' => 'Eyða töflu', + 'table_clear_formatting' => 'Hreinsa forsnið töflu', + 'resize_to_contents' => 'Endurstilla stærð innihalds', + 'row_header' => 'Titill raðar', + 'insert_row_before' => 'Líma röð á undan', + 'insert_row_after' => 'Líma röð á eftir', + 'delete_row' => 'Eyða röð', + 'insert_column_before' => 'Líma dálk á undan', + 'insert_column_after' => 'Líma dálk á eftir', + 'delete_column' => 'Eyða dálki', + 'table_cell' => 'Reitur', + 'table_row' => 'Röð', + 'table_column' => 'Dálkur', + 'cell_properties' => 'Eigindi reitar', + 'cell_properties_title' => 'Eigindi reitar', + 'cell_type' => 'Gerð reitar', + 'cell_type_cell' => 'Reitur', + 'cell_scope' => 'Svið', + 'cell_type_header' => 'For reitur', + 'merge_cells' => 'Sameina reiti', + 'split_cell' => 'Kljúfa reiti', + 'table_row_group' => 'Hópur raðar', + 'table_column_group' => 'Hópur dálks', + 'horizontal_align' => 'Jafna lárétt', + 'vertical_align' => 'Jafna lóðrétt', + 'border_width' => 'Border width', + 'border_style' => 'Útlit jaðars', + 'border_color' => 'Litur jaðars', + 'row_properties' => 'Eigindi raðar', + 'row_properties_title' => 'Eigindi raðar', + 'cut_row' => 'Klippa röð', + 'copy_row' => 'Afrita röð', + 'paste_row_before' => 'Líma röð á undan', + 'paste_row_after' => 'Líma röð á eftir', + 'row_type' => 'Gerð raðar', + 'row_type_header' => 'Síðuhaus', + 'row_type_body' => 'Meginmál', + 'row_type_footer' => 'Neðanmál', + 'alignment' => 'Jöfnun', + 'cut_column' => 'Klippa dálk', + 'copy_column' => 'Afrita dálk', + 'paste_column_before' => 'Líma dálk á undan', + 'paste_column_after' => 'Líma dálk á eftir', + 'cell_padding' => 'Cell padding', + 'cell_spacing' => 'Cell spacing', + 'caption' => 'Fyrirsögn', + 'show_caption' => 'Sýna fyrirsögn', + 'constrain' => 'Constrain proportions', + 'cell_border_solid' => 'Fyllt', + 'cell_border_dotted' => 'Punkta', + 'cell_border_dashed' => 'Strikað', + 'cell_border_double' => 'Tvöfalt', + 'cell_border_groove' => 'Groove', + 'cell_border_ridge' => 'Ridge', + 'cell_border_inset' => 'Inset', + 'cell_border_outset' => 'Outset', + 'cell_border_none' => 'Engin', + 'cell_border_hidden' => 'Falin', + + // Images, links, details/summary & embed + 'source' => 'Uppruni', + 'alt_desc' => 'Auka lýsing', + 'embed' => 'Innfellt', + 'paste_embed' => 'Límdu innfellda kóðann þinn að neðan:', + 'url' => 'Vistfang', + 'text_to_display' => 'Teksti til að sýna', + 'title' => 'Titill', + 'open_link' => 'Opna hlekk', + 'open_link_in' => 'Opna hlekk í...', + 'open_link_current' => 'Virkum glugga', + 'open_link_new' => 'Nýjum glugga', + 'remove_link' => 'Fjarlægja hlekk', + 'insert_collapsible' => 'Insert collapsible block', + 'collapsible_unwrap' => 'Unwrap', + 'edit_label' => 'Breyta miða', + 'toggle_open_closed' => 'Velja opið/lokað', + 'collapsible_edit' => 'Edit collapsible block', + 'toggle_label' => 'Sýna miða', + + // About view + 'about' => 'Um ritilinn', + 'about_title' => 'Um WYSIWYG ritilinn', + 'editor_license' => 'Leyfi og höfundaréttur ritilsins', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', + 'editor_tiny_license' => 'Þessi ritill er smíðaður með :tinyLink sem er undir MIT leyfi.', + 'editor_tiny_license_link' => 'Höfundarétt og leyfi TinyMCE má finna hér.', + 'save_continue' => 'Vista síðu og halda áfram', + 'callouts_cycle' => '(Keep pressing to toggle through types)', + 'link_selector' => 'Hlekkur á innihald', + 'shortcuts' => 'Flýtileiðir', + 'shortcut' => 'Flýtileið', + 'shortcuts_intro' => 'Eftirtaldar flýtileiðir eru aðgengilegar í ritlinum:', + 'windows_linux' => '(Windows/Linux)', + 'mac' => '(Mac)', + 'description' => 'Lýsing', +]; diff --git a/lang/is/entities.php b/lang/is/entities.php new file mode 100644 index 000000000..f4ea3ac6d --- /dev/null +++ b/lang/is/entities.php @@ -0,0 +1,460 @@ + 'Nýlega búið til', + 'recently_created_pages' => 'Nýlega stofnaðar síður', + 'recently_updated_pages' => 'Nýlega uppfærðar síður', + 'recently_created_chapters' => 'Nýlega stofnaðir kaflar', + 'recently_created_books' => 'Nýlega stofnaðar bækur', + 'recently_created_shelves' => 'Nýlega stofnaðar hillur', + 'recently_update' => 'Nýlega uppfært', + 'recently_viewed' => 'Nýlega skoðað', + 'recent_activity' => 'Nýleg virkni', + 'create_now' => 'Búðu til eina núna', + 'revisions' => 'Útgáfur', + 'meta_revision' => 'Úgáfa #:revisionCount', + 'meta_created' => 'Búið til :timeLength', + 'meta_created_name' => 'Búið til :timeLength af :user', + 'meta_updated' => 'Uppfært :timeLength', + 'meta_updated_name' => 'Uppfært :timeLength af :user', + 'meta_owned_name' => 'Eigandi :user', + 'meta_reference_count' => 'Vitnað í af :count item|Vitnað í af :count items', + 'entity_select' => 'Entity Val', + 'entity_select_lack_permission' => 'Þú hefur ekki nauðsynlegar aðgangsheimildir til að velja þetta', + 'images' => 'Myndir', + 'my_recent_drafts' => 'Nýlegur drögin mín', + 'my_recently_viewed' => 'Síðast skoða af mér', + 'my_most_viewed_favourites' => 'Mest skoðuðu eftirlætin', + 'my_favourites' => 'Eftirlætin mín', + 'no_pages_viewed' => 'Þú hefur ekki skoðað neinar síður', + 'no_pages_recently_created' => 'Engar síður hafa verið búnar til nýlega', + 'no_pages_recently_updated' => 'Engar síður hafa verið uppfærðar nýlega', + 'export' => 'Flytja út', + 'export_html' => 'Innifalin vefskrá', + 'export_pdf' => 'PDF skrá', + 'export_text' => 'Venjuleg textaskrá', + 'export_md' => 'Markdown skrá', + 'export_zip' => 'ZIP skrá', + 'default_template' => 'Sjálfgefið síðusnið', + 'default_template_explain' => 'Veldu síðusnið sem verður sjálgefið snið fyrir allar stofnaðar síður innan þessa hluta. Hafðu í huga að þetta verður aðeins notað ef sá sem stofnar síður er með heimild á þetta snið.', + 'default_template_select' => 'Veldu sniðsíðu', + 'import' => 'Flytja inn', + 'import_validate' => 'Staðfesta innflutning', + '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' => 'Veldu ZIP skrá til að hlaða upp', + 'import_zip_validation_errors' => 'Greindar voru villur við að staðreyna uppgefina ZIP skrá:', + 'import_pending' => 'Innflutningur í bið', + 'import_pending_none' => 'Ekkert hefur verið flutt inn.', + 'import_continue' => 'Halda áfram að flytja inn', + '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' => 'Nánari lýsing á innflutningi', + 'import_run' => 'Keyra innflutning', + 'import_size' => ':size Stærð ZIP skrár', + 'import_uploaded_at' => 'Uploaded :relativeTime', + 'import_uploaded_by' => 'Upphlaðið af', + 'import_location' => 'Staðsetning innflutnings', + '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' => 'Ertu viss um að þú viljir eyða þessum innflutningi?', + 'import_delete_desc' => 'Þetta mun eyða innsendri ZIP skrá, þessa aðgerð er ekki hægt að afturkalla.', + 'import_errors' => 'Villur í innflutningi', + 'import_errors_desc' => 'Eftirfarandi villur komu upp við innflutning:', + + // Permissions and restrictions + 'permissions' => 'Réttindi', + 'permissions_desc' => 'Set permissions here to override the default permissions provided by user roles.', + 'permissions_book_cascade' => 'Permissions set on books will automatically cascade to child chapters and pages, unless they have their own permissions defined.', + 'permissions_chapter_cascade' => 'Permissions set on chapters will automatically cascade to child pages, unless they have their own permissions defined.', + 'permissions_save' => 'Vista réttindi', + 'permissions_owner' => 'Eigandi', + 'permissions_role_everyone_else' => 'Allir aðrir', + 'permissions_role_everyone_else_desc' => 'Set permissions for all roles not specifically overridden.', + 'permissions_role_override' => 'Yfirskrifa réttindi fyrir hlutverk', + 'permissions_inherit_defaults' => 'Erfa sjálfgefið', + + // Search + 'search_results' => 'Leitarniðurstöður', + 'search_total_results_found' => ':count result found|:count total results found', + 'search_clear' => 'Hreinsa leit', + 'search_no_pages' => 'Engar síður passa við þessa leit', + 'search_for_term' => 'Leita að :term', + 'search_more' => 'Fleiri niðurstöður', + 'search_advanced' => 'Ítarleg leit', + 'search_terms' => 'Leitarorð', + 'search_content_type' => 'Efnistegund', + 'search_exact_matches' => 'Nákvæm samsvörun', + 'search_tags' => 'Leita í örmerkjum', + 'search_options' => 'Valkostir', + 'search_viewed_by_me' => 'Skoðað af mér', + 'search_not_viewed_by_me' => 'Ekki skoðað af mér', + 'search_permissions_set' => 'Réttindi stillt', + 'search_created_by_me' => 'Búið til af mér', + 'search_updated_by_me' => 'Uppfært af mér', + 'search_owned_by_me' => 'Í minni eigu', + 'search_date_options' => 'Dagsetningarval', + 'search_updated_before' => 'Uppfært fyrir', + 'search_updated_after' => 'Uppfært eftir', + 'search_created_before' => 'Búið til fyrir', + 'search_created_after' => 'Búið til eftir', + 'search_set_date' => 'Dagsetning', + 'search_update' => 'Uppfæra leit', + + // Shelves + 'shelf' => 'Hilla', + 'shelves' => 'Hillur', + 'x_shelves' => ':count Hilla|:count Hillur', + 'shelves_empty' => 'Engar hillur hafa verið búnar til', + 'shelves_create' => 'Búa til hillu', + 'shelves_popular' => 'Vinsælar hillur', + 'shelves_new' => 'Nýjar hillur', + 'shelves_new_action' => 'Ný hilla', + 'shelves_popular_empty' => 'Vinsælustu hillurnar munu birtast hér.', + 'shelves_new_empty' => 'Nýjustu hillurnar munu birtast hér.', + 'shelves_save' => 'Vista hillu', + 'shelves_books' => 'Bækur í þessari hillu', + 'shelves_add_books' => 'Bæta við bókum í þessa hillu', + 'shelves_drag_books' => 'Dragðu bækur hér undir til að bæta þeim í þessa hillu', + 'shelves_empty_contents' => 'Þessi hilla hefur engar bækur', + 'shelves_edit_and_assign' => 'Breyttu hillu til að setja inn bækur', + 'shelves_edit_named' => 'Breyta hillu :name', + 'shelves_edit' => 'Breyta hillu', + 'shelves_delete' => 'Eyða hillu', + 'shelves_delete_named' => 'Eyða hillu :name', + 'shelves_delete_explain' => "Þetta mun eyða hillunni ':name'. Bókum í þessari hillu verður ekki eytt.", + 'shelves_delete_confirmation' => 'Ertu viss um að þú viljir eyða hillunni?', + 'shelves_permissions' => 'Stillingar á réttindum á hillu', + 'shelves_permissions_updated' => 'Réttindi á hillu uppfærð', + 'shelves_permissions_active' => 'Réttindi á hillu virk', + 'shelves_permissions_cascade_warning' => 'Réttindi á hillum yfirfærast ekki á bækurnar sem í hillunni eru. Þetta er vegna þess að ein bók getur verið í mörgum hillum. Réttindi geta hinsvegar verið afrituð niður á bækur með því að nota valmöguleikann hér fyrir neðan.', + 'shelves_permissions_create' => 'Réttindi til að búa til hillu eru aðeins notuð til að afrita réttindi á undirliggjandi bækur með því að nota aðgerðina hér fyrir neðan. Þau stjórna ekki hvort hægt sé að búa til bækur.', + 'shelves_copy_permissions_to_books' => 'Afrita réttindi á bækur', + 'shelves_copy_permissions' => 'Afrita réttindi', + 'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this shelf to all books contained within. Before activating, ensure any changes to the permissions of this shelf have been saved.', + 'shelves_copy_permission_success' => 'Shelf permissions copied to :count books', + + // Books + 'book' => 'Bók', + 'books' => 'Bækur', + 'x_books' => ':count Bók:count Bækur', + 'books_empty' => 'Engar bækur hafa verið búnar til', + 'books_popular' => 'Vinsælar bækur', + 'books_recent' => 'Nýlegar bækur', + 'books_new' => 'Nýjar bækur', + 'books_new_action' => 'Ný bók', + 'books_popular_empty' => 'Vinsælustu bækurnar munu birtast hér.', + 'books_new_empty' => 'The most recently created books will appear here.', + 'books_create' => 'Búa til nýja bók', + 'books_delete' => 'Eyða bók', + 'books_delete_named' => 'Eyða bók :bookName', + 'books_delete_explain' => 'Þetta mun eyða bók með nafninu \':bookName\'. Allar síður og allir kaflar verða fjarlægðir og eytt.', + 'books_delete_confirmation' => 'Ertu viss um að þú viljir eyða þessari bók?', + 'books_edit' => 'Breyta bók', + 'books_edit_named' => 'Breyta bók :bookName', + 'books_form_book_name' => 'Nafn bókar', + 'books_save' => 'Vista bók', + 'books_permissions' => 'Réttindastillingar bókar', + 'books_permissions_updated' => 'Réttindastillingar bókar uppfærðar', + 'books_empty_contents' => 'Engar síður eða kaflar hafa verið búin til fyrir þessa bók.', + 'books_empty_create_page' => 'Búa til nýja síðu', + 'books_empty_sort_current_book' => 'Raða núverandi bók', + 'books_empty_add_chapter' => 'Bæta við kafla', + 'books_permissions_active' => 'Réttindi bókar virk', + 'books_search_this' => 'Leita í þessari bók', + 'books_navigation' => 'Leiðartré bókar', + 'books_sort' => 'Raða innihaldi bókar', + 'books_sort_desc' => 'Move chapters and pages within a book to reorganise its contents. Other books can be added which allows easy moving of chapters and pages between books.', + 'books_sort_named' => 'Raða bók :bookName', + 'books_sort_name' => 'Raða eftir nafni', + 'books_sort_created' => 'Raða eftir skráningar dagsetningu', + 'books_sort_updated' => 'Raða eftir upphleðslu dagsetningu', + 'books_sort_chapters_first' => 'Kaflar fyrst', + 'books_sort_chapters_last' => 'Kaflar síðast', + 'books_sort_show_other' => 'Sýna aðrar bækur', + 'books_sort_save' => 'Vista nýja röð', + 'books_sort_show_other_desc' => 'Bæta við öðrum bókum til að bæta þeim við röðunina.', + 'books_sort_move_up' => 'Færa upp', + 'books_sort_move_down' => 'Færa niður', + 'books_sort_move_prev_book' => 'Færa í fyrri bók', + 'books_sort_move_next_book' => 'Færa í næstu bók', + 'books_sort_move_prev_chapter' => 'Færa inn í fyrri kafla', + 'books_sort_move_next_chapter' => 'Færa inn í næsta kafla', + 'books_sort_move_book_start' => 'Færa til byrjunar bókar', + 'books_sort_move_book_end' => 'Færa í enda bókar', + 'books_sort_move_before_chapter' => 'Færa í byrjun kafla', + 'books_sort_move_after_chapter' => 'Færa í lok kafla', + 'books_copy' => 'Afrita bók', + 'books_copy_success' => 'Tókst að afrita bók', + + // Chapters + 'chapter' => 'Kafli', + 'chapters' => 'Kaflar', + 'x_chapters' => ':count Kafli|:count Kaflar', + 'chapters_popular' => 'Vinsælir kaflar', + 'chapters_new' => 'Nýr kafli', + 'chapters_create' => 'Búa til nýjan kafla', + 'chapters_delete' => 'Eyða kafla', + 'chapters_delete_named' => 'Eyða kafla :chapterName', + 'chapters_delete_explain' => 'Þetta mun eyða kafla með nafninu \':chapterName\'. Öllum blaðsíðum í þessum kafla verður einnig eytt.', + 'chapters_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum kafla?', + 'chapters_edit' => 'Breyta kafla', + 'chapters_edit_named' => 'Breyta kafla chapterName', + 'chapters_save' => 'Vista kafla', + 'chapters_move' => 'Færa kafla', + 'chapters_move_named' => 'Færa kalfa :chapterName', + 'chapters_copy' => 'Afrita kafla', + 'chapters_copy_success' => 'Tókst að afrita kafla', + 'chapters_permissions' => 'Réttindi á kafla', + 'chapters_empty' => 'Engar síður eru eins og er í þessum kafla.', + 'chapters_permissions_active' => 'Réttindi á kafla eru virk', + 'chapters_permissions_success' => 'Réttindi á kafla hafa verið uppfærð', + 'chapters_search_this' => 'Leita í þessum kafla', + 'chapter_sort_book' => 'Raða bók', + + // Pages + 'page' => 'Síða', + 'pages' => 'Síður', + 'x_pages' => ':count Síða|:count Síður', + 'pages_popular' => 'Vinsælar síður', + 'pages_new' => 'Ný síða', + 'pages_attachments' => 'Viðhengi', + 'pages_navigation' => 'Síðuráp', + 'pages_delete' => 'Eyða síðu', + 'pages_delete_named' => 'Eyða síðu :pageName', + 'pages_delete_draft_named' => 'Eyða drögum :pageName', + 'pages_delete_draft' => 'Eyða uppkasti', + 'pages_delete_success' => 'Síðu eytt', + 'pages_delete_draft_success' => 'Uppkasti að síðu eytt', + 'pages_delete_warning_template' => 'This page is in active use as a book or chapter default page template. These books or chapters will no longer have a default page template assigned after this page is deleted.', + 'pages_delete_confirm' => 'Ertu viss um að þú viljir eyða þessari síðu?', + 'pages_delete_draft_confirm' => 'Ertu viss um að þú viljir eyða þessu uppkasti að síðu?', + 'pages_editing_named' => 'Breyta síðu :pageName', + 'pages_edit_draft_options' => 'Valkostir uppkasts', + 'pages_edit_save_draft' => 'Vista uppkast', + 'pages_edit_draft' => 'Breyta drögum', + 'pages_editing_draft' => 'Breyta uppkasti', + 'pages_editing_page' => 'Breyta síðu', + 'pages_edit_draft_save_at' => 'Vista uppkast ', + 'pages_edit_delete_draft' => 'Eyða uppkasti', + 'pages_edit_delete_draft_confirm' => 'Ertu viss um að þú viljir eyða uppkasti síðu? Allar breytingar sem gerðar hafa verið frá síðustu vistun á síðunni munu tapast.', + 'pages_edit_discard_draft' => 'Henda uppkasti', + 'pages_edit_switch_to_markdown' => 'Færa þig yfir í Markdown ritil', + 'pages_edit_switch_to_markdown_clean' => '(Hreinsa innihald)', + 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', + 'pages_edit_switch_to_wysiwyg' => 'Skipta yfir í WYSIWYG ritil', + 'pages_edit_switch_to_new_wysiwyg' => 'Skipta yfir í nýja WYSIWYG ritilinn', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(Í alfa prófun)', + 'pages_edit_set_changelog' => 'Stilla breytingarskrá', + 'pages_edit_enter_changelog_desc' => 'Skrifaðu stutta lýsingu á breytingunum sem þú gerðir', + 'pages_edit_enter_changelog' => 'Færa í breytingaskrá', + 'pages_editor_switch_title' => 'Skipta um ritil', + 'pages_editor_switch_are_you_sure' => 'Are you sure you want to change the editor for this page?', + 'pages_editor_switch_consider_following' => 'Consider the following when changing editors:', + 'pages_editor_switch_consideration_a' => 'Once saved, the new editor option will be used by any future editors, including those that may not be able to change editor type themselves.', + 'pages_editor_switch_consideration_b' => 'This can potentially lead to a loss of detail and syntax in certain circumstances.', + 'pages_editor_switch_consideration_c' => 'Tag or changelog changes, made since last save, won\'t persist across this change.', + 'pages_save' => 'Vista síðu', + 'pages_title' => 'Titill síðu', + 'pages_name' => 'Nafn síðu', + 'pages_md_editor' => 'Ritill', + 'pages_md_preview' => 'Forskoðun', + 'pages_md_insert_image' => 'Setja inn mynd', + 'pages_md_insert_link' => 'Insert Entity Link', + 'pages_md_insert_drawing' => 'Setja inn teikningu', + 'pages_md_show_preview' => 'Sýna forskoðun', + 'pages_md_sync_scroll' => 'Sync preview scroll', + 'pages_drawing_unsaved' => 'Unsaved Drawing Found', + 'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?', + 'pages_not_in_chapter' => 'Síðan tilheyrir engum kafla', + 'pages_move' => 'Færa síðu', + 'pages_copy' => 'Afrita síðu', + 'pages_copy_desination' => 'Áfangastaður afritunar', + 'pages_copy_success' => 'Tókst að afrita síðu', + 'pages_permissions' => 'Réttindi síðu', + 'pages_permissions_success' => 'Réttindi síðu uppfærð', + 'pages_revision' => 'Útgáfa', + 'pages_revisions' => 'Útgáfur síðu', + 'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.', + 'pages_revisions_named' => 'Page Revisions for :pageName', + 'pages_revision_named' => 'Page Revision for :pageName', + 'pages_revision_restored_from' => 'Restored from #:id; :summary', + 'pages_revisions_created_by' => 'Búið til af', + 'pages_revisions_date' => 'Útgáfu dagsetning', + 'pages_revisions_number' => '#', + 'pages_revisions_sort_number' => 'Útgáfunúmer', + 'pages_revisions_numbered' => 'Útgáfu #:id', + 'pages_revisions_numbered_changes' => 'Útgáfu #:id breytingar', + 'pages_revisions_editor' => 'Gerð ritils', + 'pages_revisions_changelog' => 'Breytingaskrá', + 'pages_revisions_changes' => 'Breytingar', + 'pages_revisions_current' => 'Núverandi útgáfa', + 'pages_revisions_preview' => 'Forskoðun', + 'pages_revisions_restore' => 'Endurheimta', + 'pages_revisions_none' => 'Þessi síða hefur engar útgáfur', + 'pages_copy_link' => 'Afrita hlekk', + 'pages_edit_content_link' => 'Hoppa í staðsetningu í ritli', + 'pages_pointer_enter_mode' => 'Enter section select mode', + 'pages_pointer_label' => 'Page Section Options', + 'pages_pointer_permalink' => 'Page Section Permalink', + 'pages_pointer_include_tag' => 'Page Section Include Tag', + 'pages_pointer_toggle_link' => 'Permalink mode, Press to show include tag', + 'pages_pointer_toggle_include' => 'Include tag mode, Press to show permalink', + 'pages_permissions_active' => 'Page Permissions Active', + 'pages_initial_revision' => 'Fyrsta birting', + 'pages_references_update_revision' => 'System auto-update of internal links', + 'pages_initial_name' => 'Ný síða', + 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', + 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', + 'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count users have started editing this page', + 'start_b' => ':userName has started editing this page', + 'time_a' => 'since the page was last updated', + 'time_b' => 'in the last :minCount minutes', + 'message' => ':start :time. Take care not to overwrite each other\'s updates!', + ], + 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', + 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', + 'pages_specific' => 'Tilgreind síða', + 'pages_is_template' => 'Forsnið síðu', + + // Editor Sidebar + 'toggle_sidebar' => 'Toggle Sidebar', + 'page_tags' => 'Page Tags', + 'chapter_tags' => 'Chapter Tags', + 'book_tags' => 'Book Tags', + 'shelf_tags' => 'Shelf Tags', + 'tag' => 'Tag', + 'tags' => 'Tags', + 'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.', + 'tag_name' => 'Tag Name', + 'tag_value' => 'Tag Value (Optional)', + 'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.", + 'tags_add' => 'Add another tag', + 'tags_remove' => 'Remove this tag', + 'tags_usages' => 'Total tag usages', + 'tags_assigned_pages' => 'Assigned to Pages', + 'tags_assigned_chapters' => 'Assigned to Chapters', + 'tags_assigned_books' => 'Assigned to Books', + 'tags_assigned_shelves' => 'Assigned to Shelves', + 'tags_x_unique_values' => ':count unique values', + 'tags_all_values' => 'All values', + 'tags_view_tags' => 'View Tags', + 'tags_view_existing_tags' => 'View existing tags', + 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', + 'attachments' => 'Attachments', + 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', + 'attachments_explain_instant_save' => 'Changes here are saved instantly.', + 'attachments_upload' => 'Upload File', + 'attachments_link' => 'Attach Link', + 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', + 'attachments_set_link' => 'Set Link', + 'attachments_delete' => 'Are you sure you want to delete this attachment?', + 'attachments_dropzone' => 'Drop files here to upload', + 'attachments_no_files' => 'No files have been uploaded', + 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', + 'attachments_link_name' => 'Link Name', + 'attachment_link' => 'Attachment link', + 'attachments_link_url' => 'Link to file', + 'attachments_link_url_hint' => 'Url of site or file', + 'attach' => 'Attach', + 'attachments_insert_link' => 'Add Attachment Link to Page', + 'attachments_edit_file' => 'Edit File', + 'attachments_edit_file_name' => 'File Name', + 'attachments_edit_drop_upload' => 'Drop files or click here to upload and overwrite', + 'attachments_order_updated' => 'Attachment order updated', + 'attachments_updated_success' => 'Attachment details updated', + 'attachments_deleted' => 'Attachment deleted', + 'attachments_file_uploaded' => 'File successfully uploaded', + 'attachments_file_updated' => 'File successfully updated', + 'attachments_link_attached' => 'Link successfully attached to page', + 'templates' => 'Templates', + 'templates_set_as_template' => 'Page is a template', + 'templates_explain_set_as_template' => 'You can set this page as a template so its contents be utilized when creating other pages. Other users will be able to use this template if they have view permissions for this page.', + 'templates_replace_content' => 'Replace page content', + 'templates_append_content' => 'Append to page content', + 'templates_prepend_content' => 'Prepend to page content', + + // Profile View + 'profile_user_for_x' => 'User for :time', + 'profile_created_content' => 'Created Content', + 'profile_not_created_pages' => ':userName has not created any pages', + 'profile_not_created_chapters' => ':userName has not created any chapters', + 'profile_not_created_books' => ':userName has not created any books', + 'profile_not_created_shelves' => ':userName has not created any shelves', + + // Comments + 'comment' => 'Comment', + 'comments' => 'Comments', + 'comment_add' => 'Add Comment', + 'comment_placeholder' => 'Leave a comment here', + 'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments', + 'comment_save' => 'Save Comment', + 'comment_new' => 'New Comment', + 'comment_created' => 'commented :createDiff', + 'comment_updated' => 'Updated :updateDiff by :username', + 'comment_updated_indicator' => 'Updated', + 'comment_deleted_success' => 'Comment deleted', + 'comment_created_success' => 'Comment added', + 'comment_updated_success' => 'Comment updated', + 'comment_delete_confirm' => 'Are you sure you want to delete this comment?', + 'comment_in_reply_to' => 'In reply to :commentId', + 'comment_editor_explain' => 'Here are the comments that have been left on this page. Comments can be added & managed when viewing the saved page.', + + // Revision + 'revision_delete_confirm' => 'Are you sure you want to delete this revision?', + 'revision_restore_confirm' => 'Are you sure you want to restore this revision? The current page contents will be replaced.', + 'revision_cannot_delete_latest' => 'Cannot delete the latest revision.', + + // Copy view + 'copy_consider' => 'Please consider the below when copying content.', + 'copy_consider_permissions' => 'Custom permission settings will not be copied.', + 'copy_consider_owner' => 'You will become the owner of all copied content.', + 'copy_consider_images' => 'Page image files will not be duplicated & the original images will retain their relation to the page they were originally uploaded to.', + 'copy_consider_attachments' => 'Page attachments will not be copied.', + 'copy_consider_access' => 'A change of location, owner or permissions may result in this content being accessible to those previously without access.', + + // Conversions + 'convert_to_shelf' => 'Convert to Shelf', + 'convert_to_shelf_contents_desc' => 'You can convert this book to a new shelf with the same contents. Chapters contained within this book will be converted to new books. If this book contains any pages, that are not in a chapter, this book will be renamed and contain such pages, and this book will become part of the new shelf.', + 'convert_to_shelf_permissions_desc' => 'Any permissions set on this book will be copied to the new shelf and to all new child books that don\'t have their own permissions enforced. Note that permissions on shelves do not auto-cascade to content within, as they do for books.', + 'convert_book' => 'Convert Book', + 'convert_book_confirm' => 'Are you sure you want to convert this book?', + 'convert_undo_warning' => 'This cannot be as easily undone.', + 'convert_to_book' => 'Convert to Book', + 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', + 'convert_chapter' => 'Convert Chapter', + 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + + // References + 'references' => 'References', + 'references_none' => 'There are no tracked references to this item.', + 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.', + + // Watch Options + 'watch' => 'Watch', + 'watch_title_default' => 'Default Preferences', + 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_title_ignore' => 'Ignore', + 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_title_new' => 'New Pages', + 'watch_desc_new' => 'Notify when any new page is created within this item.', + 'watch_title_updates' => 'All Page Updates', + 'watch_desc_updates' => 'Notify upon all new pages and page changes.', + 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', + 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', + 'watch_change_default' => 'Change default notification preferences', + 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_detail_new' => 'Watching for new pages', + 'watch_detail_updates' => 'Watching new pages and updates', + 'watch_detail_comments' => 'Watching new pages, updates & comments', + 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', + 'watch_detail_parent_chapter' => 'Watching via parent chapter', + 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', +]; diff --git a/lang/is/errors.php b/lang/is/errors.php new file mode 100644 index 000000000..50e30a8c5 --- /dev/null +++ b/lang/is/errors.php @@ -0,0 +1,133 @@ + 'Þú hefur ekki heimild til að skoða þessa síðu.', + 'permissionJson' => 'Þú hefur ekki heimild til að framkvæma þessa aðgerð.', + + // Auth + 'error_user_exists_different_creds' => 'Notandi með netfangið :email er nú þegar til.', + 'auth_pre_register_theme_prevention' => 'Ekki var hægt að búa til aðgang með þessum upplýsingum', + 'email_already_confirmed' => 'Netfang hefur þegar verið staðfest. Prófaðu að skrá þig inn.', + 'email_confirmation_invalid' => 'Þessi staðfestingar tóki er ekki gildur eða hefur þegar verið notaður. Reyndu að skrá þig aftur.', + 'email_confirmation_expired' => 'Staðfestingar tóki hefur runnið út. Nýr staðfestinga tölvupóstur hefur verið sendur.', + 'email_confirmation_awaiting' => 'Eftir á að staðfest þetta netfang', + 'ldap_fail_anonymous' => 'LDAP auðkenning virkaði ekki', + 'ldap_fail_authed' => 'LDAP auðkenning virkaði ekki með að nota uppgefið dn & password', + 'ldap_extension_not_installed' => 'LDAP PHP viðbót ekki uppsett', + 'ldap_cannot_connect' => 'Næ ekki að tengjast Ldap þjóni. Fyrsta tenging mistókst', + 'saml_already_logged_in' => 'Þegar innskráður', + 'saml_no_email_address' => 'Fann ekki netfang fyrir þennan notanda í auðkenningar þjónustu', + 'saml_invalid_response_id' => 'Beiðnin frá ytri auðkenningaraðila er óþekkt af kerfinu. Að fara tilbaka eftir innskráningu gæti valdið þessu vandamáli.', + 'saml_fail_authed' => 'Innskráning sem notaði :system tókst ekki. Kerfið gaf ekki út gilda auðkenningu', + 'oidc_already_logged_in' => 'Þegar skráður inn', + 'oidc_no_email_address' => 'Fann ekki netfang fyrir þennan notanda í ytri auðkenningar þjónustu', + 'oidc_fail_authed' => 'Innskráning sem notaði :system tókst ekki. Kerfið gaf ekki út gilda auðkenningu', + 'social_no_action_defined' => 'Engin aðgerð skilgreind', + 'social_login_bad_response' => "Villa kom upp við auðkenninga á :socialAccount login", + 'social_account_in_use' => 'Þessi :socialAccount er þegar í notkun. Reyndu að skrá þig inn með :socialAccount.', + 'social_account_email_in_use' => 'Netfangið :email er þegar í notkun. Ef þú ert nú þegar með aðgang getur þú tengt :socialAccount við hann í prófíl stillingum.', + 'social_account_existing' => 'Þessi :socialAccount er nú þegar tengdur við prófílinn þinn.', + 'social_account_already_used_existing' => 'Þessi :socialAccount reikningur er nú þegar í notkun hjá öðrum notanda.', + 'social_account_not_used' => 'Þessi :socialAccount er ekki tengdur neinum notanda. Þú getur tengt hann við þig í prófíl stillingar. ', + 'social_account_register_instructions' => 'Ef þú ert ekki nú þegar með aðgang, getur þú skrá þig með :socialAccount', + 'social_driver_not_found' => 'Samfélagsviðbót fannst ekki', + 'social_driver_not_configured' => 'Þínar :socialAccount er ekki rétt stilltar.', + 'invite_token_expired' => 'Þess boðshlekkur er útrunninn, Prófa að endurstilla lykilorðið þitt.', + 'login_user_not_found' => 'Enginn notandi fannst fyrir þessa aðgerð.', + + // System + 'path_not_writable' => 'Ekki var hægt að hlaða upp á slóðinni :filePath. Vertu viss um að slóðin sé skrifanleg.', + 'cannot_get_image_from_url' => 'Get ekki sótt mynd frá :url', + 'cannot_create_thumbs' => 'Netþjónninn getur ekki búið til smámyndir. Vertu viss um að þú hafir GD PHP viðbótina uppsetta.', + 'server_upload_limit' => 'Þessi netþjónn leyfir ekki uphal af þessari stærð. Prófaðu minni skrá.', + 'server_post_limit' => 'Netþjóninn getur ekki tekið á móti þessu magni gagna. Reyndu aftur með færri eða smærri gögnum.', + 'uploaded' => 'Þessi netþjónn leyfir ekki uphal af þessari stærð. Prófaðu minni skrá.', + + // Drawing & Images + 'image_upload_error' => 'Villa kom upp við að hlaða upp mynd', + 'image_upload_type_error' => 'Gerð myndar er ógild', + 'image_upload_replace_type' => 'Myndin sem á að nota við útskipti þarf að vera sömu gerðar', + 'image_upload_memory_limit' => 'Ekki var hægt að taka við upphali og eða búa til smámyndir þar sem ekki eru auðlindir til staðar.', + 'image_thumbnail_memory_limit' => 'Ekki var hægt að búa til nokkrar stærðir myndarinnar vegna skorts á auðlindum.', + 'image_gallery_thumbnail_memory_limit' => 'Ekki var hægt að búa til smámyndayfirlit vegna skorts á auðlindum.', + 'drawing_data_not_found' => 'Ekki tóks að hlaða inn teikningagögnum. Það gæti vantað skránna eða að þú hafir ekki réttindi að henni.', + + // Attachments + 'attachment_not_found' => 'Viðhengi fannst ekki', + 'attachment_upload_error' => 'Það kom upp villa við að hlaða upp viðhenginu', + + // Pages + 'page_draft_autosave_fail' => 'Gat ekki vistað uppkast. Gættu að þú hafir tengingu við internetið áður en þú vistar þessa síðu', + 'page_draft_delete_fail' => 'Ekki var hægt að eyða uppkasti og sækja fyrra innihald síðunar', + 'page_custom_home_deletion' => 'Ekki er hægt að eyða síðu á meðan hún er valin sem sjálfgefin upphafssíða', + + // Entities + 'entity_not_found' => 'Entity fannst ekki', + 'bookshelf_not_found' => 'Hilla fannst ekki', + 'book_not_found' => 'Bók fannst ekki', + 'page_not_found' => 'Síða fannst ekki', + 'chapter_not_found' => 'Kafli fannst ekki', + 'selected_book_not_found' => 'Valin bók fannst ekki', + 'selected_book_chapter_not_found' => 'Valin bók eða kafli fannst ekki', + 'guests_cannot_save_drafts' => 'Gestir geta ekki vistað drög', + + // Users + 'users_cannot_delete_only_admin' => 'Þú getur ekki eytt, bara kerfisstjóri', + 'users_cannot_delete_guest' => 'Þú getur ekki eytt gesta notanda', + 'users_could_not_send_invite' => 'Gat ekki stofnað notanda þar sem ekki tókst að senda staðfestingar tölvupóst', + + // Roles + 'role_cannot_be_edited' => 'Ekki er hægt að breyta þessu hlutverki', + 'role_system_cannot_be_deleted' => 'Þetta er kerfis hlutverk og því ekki hægt að eyða því', + 'role_registration_default_cannot_delete' => 'Ekki er hægt að eyða þessu hlutverki þar sem það er sjálfgefið kerfishlutverk við skráningu', + 'role_cannot_remove_only_admin' => 'Þessi notandi er sá eini sem er með kerfisstjóra hlutverk. Bættu hlutverkinu við annann notanda áður en þú reynir að fjarlægja það héðan.', + + // Comments + 'comment_list' => 'Villa kom upp við að sækja athugasemdir.', + 'cannot_add_comment_to_draft' => 'Þú getur ekki sett athugasemdir við drög.', + 'comment_add' => 'Villa kom upp við að bæta við eða breyta athugasemdinni.', + 'comment_delete' => 'Villa kom upp við að eyða athugasemdinni.', + 'empty_comment' => 'Get ekki bætt við tómri athugasemd.', + + // Error pages + '404_page_not_found' => 'Síða fannst ekki', + 'sorry_page_not_found' => 'Síðan sem þú varst að leita að fannst því miður ekki.', + 'sorry_page_not_found_permission_warning' => 'Ef þú átt von á að þessi síða sé til gæti verið að þú hafir ekki aðgang að henni.', + 'image_not_found' => 'Fann ekki mynd', + 'image_not_found_subtitle' => 'Myndin sem þú varst að leita að fannst því miður ekki.', + 'image_not_found_details' => 'Ef þú heldur að þessi mynda hafi verið til, þá gæti henni hafa verið eytt.', + 'return_home' => 'Fara á forsíðu', + 'error_occurred' => 'Það kom upp villa', + 'app_down' => ':appName er niðri í augnablikinu', + 'back_soon' => 'Verð komin upp aftur fljótlega.', + + // Import + 'import_zip_cant_read' => 'Gat ekki lesið ZIP skrá.', + 'import_zip_cant_decode_data' => 'Fann ekki ZIP data.json innihald.', + 'import_zip_no_data' => 'ZIP skráin inniheldur ekkert efni.', + 'import_validation_failed' => 'ZIP skráin stóðst ekki staðfestingu og skilaði villu:', + 'import_zip_failed_notification' => 'Gat ekki lesið inn ZIP skrá.', + 'import_perms_books' => 'Þú hefur ekki heimild til að búa til bækur.', + 'import_perms_chapters' => 'Þú hefur ekki heimild til að búa til kafla.', + 'import_perms_pages' => 'Þú hefur ekki heimild til að búa til síður.', + 'import_perms_images' => 'Þú hefur ekki heimild til að búa til myndir.', + 'import_perms_attachments' => 'Þú hefur ekki heimild til að búa til viðhengi.', + + // API errors + 'api_no_authorization_found' => 'Engin auðkenningar tóki fannst í aðgerðinni', + 'api_bad_authorization_format' => 'Auðkenningar tóki fannst með aðgerðinni en snið hans er rangt', + 'api_user_token_not_found' => 'Engin API tóki fannst á móti þessum auðkenningar tóka', + 'api_incorrect_token_secret' => 'Leyndarmálið sem gefið var upp fyrir API tókann er rangt', + 'api_user_no_api_permission' => 'Eigandi API tókans hefur ekki heimild til að gera API köll', + 'api_user_token_expired' => 'Auðkenningar tókin er útrunninn', + + // Settings & Maintenance + 'maintenance_test_email_failure' => 'Villa kom upp viðað reyna senda prufu tölvupóst:', + + // HTTP errors + 'http_ssr_url_no_match' => 'Þetta vistfang stemmir ekki við leyfða SSR biðlara', +]; diff --git a/lang/is/notifications.php b/lang/is/notifications.php new file mode 100644 index 000000000..b4fe01ad7 --- /dev/null +++ b/lang/is/notifications.php @@ -0,0 +1,27 @@ + 'Ný athugasemd á síðu :pageName', + 'new_comment_intro' => 'Notandi hefur sett inn athugasemd á síðu á :appName:', + 'new_page_subject' => 'Ný síða á: :pageName', + 'new_page_intro' => 'Ný síða hefur verið búin til á :appName:', + 'updated_page_subject' => 'Uppfærð síða á: :pageName', + 'updated_page_intro' => 'Síða hefur verið uppfærð á :appName:', + 'updated_page_debounce' => 'Til að fyrirbyggja fjöldatilkynningar verður þér ekki sendar tilkynningar í smá stund um uppfærslu á þessari síðu frá sama höfundi.', + + 'detail_page_name' => 'Síðunafn:', + 'detail_page_path' => 'Síðuslóð:', + 'detail_commenter' => 'Notandi:', + 'detail_comment' => 'Athugasemd:', + 'detail_created_by' => 'Búið til af:', + 'detail_updated_by' => 'Uppfært af:', + + 'action_view_comment' => 'Skoða athugasemd', + 'action_view_page' => 'Skoða síðu', + + 'footer_reason' => 'Þessi tilkynning var send til þín vegna :link nær yfir þessa virkni á þessum hlut.', + 'footer_reason_link' => 'stillingar á tilkynningum til þín', +]; diff --git a/lang/is/pagination.php b/lang/is/pagination.php new file mode 100644 index 000000000..3eca2f26e --- /dev/null +++ b/lang/is/pagination.php @@ -0,0 +1,12 @@ + '« Fyrri', + 'next' => 'Næsta»', + +]; diff --git a/lang/is/passwords.php b/lang/is/passwords.php new file mode 100644 index 000000000..b8c093b03 --- /dev/null +++ b/lang/is/passwords.php @@ -0,0 +1,15 @@ + 'Lykilorð verður að vera að lágmarki 8 stafir og stemma saman.', + 'user' => "Enginn notandi finnst með þetta netfang.", + 'token' => 'Tókinn er ógildur fyrir þetta netfang.', + 'sent' => 'Þér hefur verið sendur hlekkur í tölvupósti!', + 'reset' => 'Lykilorðinu hefur verið breytt!', + +]; diff --git a/lang/is/preferences.php b/lang/is/preferences.php new file mode 100644 index 000000000..b7ebc2df0 --- /dev/null +++ b/lang/is/preferences.php @@ -0,0 +1,51 @@ + 'Minn aðgangur', + + 'shortcuts' => 'Flýtileiðir', + 'shortcuts_interface' => 'UI, stillingar flýtileiða', + 'shortcuts_toggle_desc' => 'Hér getur þú virkjað eða óvirkjað flýtilykla, notað fyrir leiðarstýringu og aðgerðir.', + 'shortcuts_customize_desc' => 'Þú getur stillt alla flýtilyklana hér að neðan. Þú ýtir þá þann flýtilykil sem þú vilt nota eftir að þú hefur valið innsláttarleið.', + 'shortcuts_toggle_label' => 'Flýtilyklar virkjaðir', + 'shortcuts_section_navigation' => 'Leiðarstýring', + 'shortcuts_section_actions' => 'Algengar aðgerðir', + 'shortcuts_save' => 'Vista flýtilykla', + 'shortcuts_overlay_desc' => 'Ath: þegar flýtilyklar eru virkjaðir er hægt að fá aðstoð með því að ýta á "?" sem mun yfirstrika þær flýtileiðir sem í boði eru.', + 'shortcuts_update_success' => 'Stillingar flýtilykla hafa verið uppfærðar!', + 'shortcuts_overview_desc' => 'Stjórna þeim flýtilyklum sem í boði eru.', + + 'notifications' => 'Stillingar tilkynninga', + 'notifications_desc' => 'Stýrðu þeim tölvupóst tilkynningum sem þú færð þegar ákveðnar aðgerðir eru gerðar af kerfinu.', + 'notifications_opt_own_page_changes' => 'Láta vita þegar gerðar eru breytingar á síðum sem ég á', + 'notifications_opt_own_page_comments' => 'Láta vita þegar gerðar eru athugasmedir við síður sem ég á', + 'notifications_opt_comment_replies' => 'Láta vita þegar athugasemdum mínum er svarað', + 'notifications_save' => 'Vista stillingar', + 'notifications_update_success' => 'Stillingar á tilkynningum hafa verið uppfærðar!', + 'notifications_watched' => 'Watched & Ignored Items', + 'notifications_watched_desc' => 'Fyrir neðan eru hlutir sem hafa sérsniðna eftirfylgni stillta. Til að uppfæra stillingarnar fyrir þessa hluti skaltu skoða hann og og finna skoðastillinguna í hliðarstikunni.', + + 'auth' => 'Aðgangur og öryggi', + 'auth_change_password' => 'Breyta lykilorði', + 'auth_change_password_desc' => 'Breyta lykilorðinu sem þú notar til að skrá þig inn í hugbúnaðinn. Lykilorðið verður að vera a. m. k 8 stafa langt.', + 'auth_change_password_success' => 'Lykilorði hefur verið breytt!', + + 'profile' => 'Upplýsingar um prófíl', + 'profile_desc' => 'Stýra þeim upplýsingum um þig sem aðrir notendur sjá.', + 'profile_view_public' => 'Skoða almennan prófíl', + 'profile_name_desc' => 'Stilla þitt notendanafn sem er sýnlegt öðrum notendum.', + 'profile_email_desc' => 'Þetta netfang verður notað fyrir tilkynningar og aðgang að kerfinu hafir þú valið svo.', + 'profile_email_no_permission' => 'Þú hefur ekki heimild til að breyta netfanginu þinu. Ef þú vilt láta breyta því verður þú að hafa samband við kerfisstjóra.', + 'profile_avatar_desc' => 'Veldu mynd til að sýna öðrum notendum. Helst þarf þessi mynd að vera ferköntuð og um það bil 256px bæði á breidd og hæð.', + 'profile_admin_options' => 'Stillingar kerfisstjóra', + 'profile_admin_options_desc' => 'Viðbótar kerfistjóra stillingar, til dæmis stjórnun á hlutverkum sem finna á í "Stillingar > Notendur svæði hugbúnaðarins.', + + 'delete_account' => 'Eyða aðgangi', + 'delete_my_account' => 'Eyða reikningi mínum', + 'delete_my_account_desc' => 'Þetta mun eyða þínum aðgangi að hugbúnaðinum. Þú munt ekki geta enduheimt aðganginn. Efni sem þú hefur búið til eins og síður og þær myndir sem þú hefur sent inn munu halda sér.', + 'delete_my_account_warning' => 'Ertu viss um að þú viljir eyða aðganginum þínum?', +]; diff --git a/lang/is/settings.php b/lang/is/settings.php new file mode 100644 index 000000000..14b0ec8e3 --- /dev/null +++ b/lang/is/settings.php @@ -0,0 +1,339 @@ + 'Stillingar', + 'settings_save' => 'Vista stillingar', + 'system_version' => 'Kerfisútgáfa', + 'categories' => 'Flokkar', + + // App Settings + 'app_customization' => 'Sérsníða', + 'app_features_security' => 'Eigindi og öryggi', + 'app_name' => 'Nafn kerfis', + 'app_name_desc' => 'Þetta nafn er sýnd í titli og í öllum tölvupóstum sem sendir eru.', + 'app_name_header' => 'Sýna nafn í titli', + 'app_public_access' => 'Almennur aðgangur', + 'app_public_access_desc' => 'Með því að virkja þennan valmöguleika munu notendur sem eru ekki skráðir inn geta skoðað innihald vefsins.', + 'app_public_access_desc_guest' => 'Hægt er að stýra almennum aðgangi í gegnum "Guest" notandann.', + 'app_public_access_toggle' => 'Leyfa almennann aðgang', + 'app_public_viewing' => 'Leyfa almenna skoðun?', + 'app_secure_images' => 'Aukið öryggi á mynda upphal', + 'app_secure_images_toggle' => 'Virkja aukið öryggi á mynda upphal', + 'app_secure_images_desc' => 'For performance reasons, all images are public. This option adds a random, hard-to-guess string in front of image urls. Ensure directory indexes are not enabled to prevent easy access.', + 'app_default_editor' => 'Sjálfgefin ritill', + 'app_default_editor_desc' => 'Veldu hvaða ritil á að nota sjálfgefið þegar unnið er með nýjar síður. Hægt er að yfirskrifa þessa stillingu á hverri síðu ef viðkomandi er með réttindi.', + 'app_custom_html' => 'Custom HTML Head Content', + 'app_custom_html_desc' => 'Any content added here will be inserted into the bottom of the section of every page. This is handy for overriding styles or adding analytics code.', + 'app_custom_html_disabled_notice' => 'Custom HTML head content is disabled on this settings page to ensure any breaking changes can be reverted.', + 'app_logo' => 'Lógó síðu', + 'app_logo_desc' => 'This is used in the application header bar, among other areas. This image should be 86px in height. Large images will be scaled down.', + 'app_icon' => 'Íkon síðu', + 'app_icon_desc' => 'Þetta íkon er notað í tabs í vöfrum og sem flýtivísir á síðu. Þetta ætti að vera 256px PNG mynd.', + 'app_homepage' => 'Heimasíða', + 'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.', + 'app_homepage_select' => 'Veldu síðu', + 'app_footer_links' => 'Neðangreins hlekkir', + 'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".', + 'app_footer_links_label' => 'Miði hlekks', + 'app_footer_links_url' => 'Vistfang hlekks', + 'app_footer_links_add' => 'Bæta við neðangreinshlekk', + 'app_disable_comments' => 'Óvirkja athugasemdir', + 'app_disable_comments_toggle' => 'Óvirkja athugasemdir', + 'app_disable_comments_desc' => 'Disables comments across all pages in the application.
    Existing comments are not shown.', + + // Color settings + 'color_scheme' => 'Litaþema hugbúnaðar', + 'color_scheme_desc' => 'Set the colors to use in the application user interface. Colors can be configured separately for dark and light modes to best fit the theme and ensure legibility.', + 'ui_colors_desc' => 'Set the application primary color and default link color. The primary color is mainly used for the header banner, buttons and interface decorations. The default link color is used for text-based links and actions, both within written content and in the application interface.', + 'app_color' => 'Aðal litur', + 'link_color' => 'Aðal litur hlekkja', + 'content_colors_desc' => 'Set colors for all elements in the page organisation hierarchy. Choosing colors with a similar brightness to the default colors is recommended for readability.', + 'bookshelf_color' => 'Litur hillu', + 'book_color' => 'Litur Bóka', + 'chapter_color' => 'Litur kalfa', + 'page_color' => 'Litur síðu', + 'page_draft_color' => 'Litur draga', + + // Registration Settings + 'reg_settings' => 'Skráning', + 'reg_enable' => 'Virkja skráningar', + 'reg_enable_toggle' => 'Virkja skráningar', + 'reg_enable_desc' => 'When registration is enabled user will be able to sign themselves up as an application user. Upon registration they are given a single, default user role.', + 'reg_default_role' => 'Sjálfgefið hlutverk notanda eftir skráningu', + 'reg_enable_external_warning' => 'The option above is ignored while external LDAP or SAML authentication is active. User accounts for non-existing members will be auto-created if authentication, against the external system in use, is successful.', + 'reg_email_confirmation' => 'Tölvupóst staðfesting', + 'reg_email_confirmation_toggle' => 'Krefast staðfestingar í tölvupósti', + 'reg_confirm_email_desc' => 'If domain restriction is used then email confirmation will be required and this option will be ignored.', + 'reg_confirm_restrict_domain' => 'Læsingar á lén', + 'reg_confirm_restrict_domain_desc' => 'Enter a comma separated list of email domains you would like to restrict registration to. Users will be sent an email to confirm their address before being allowed to interact with the application.
    Note that users will be able to change their email addresses after successful registration.', + 'reg_confirm_restrict_domain_placeholder' => 'Engin skilyrði sett', + + // Maintenance settings + 'maint' => 'Viðhald', + 'maint_image_cleanup' => 'Taka til í myndum', + 'maint_image_cleanup_desc' => 'Scans page & revision content to check which images and drawings are currently in use and which images are redundant. Ensure you create a full database and image backup before running this.', + 'maint_delete_images_only_in_revisions' => 'Also delete images that only exist in old page revisions', + 'maint_image_cleanup_run' => 'Keyra hreinsun', + 'maint_image_cleanup_warning' => ':count potentially unused images were found. Are you sure you want to delete these images?', + 'maint_image_cleanup_success' => ':count potentially unused images found and deleted!', + 'maint_image_cleanup_nothing_found' => 'Engar ónotaðar myndir fundust, engu eytt!', + 'maint_send_test_email' => 'Senda prufu tölvupóst', + 'maint_send_test_email_desc' => 'Þessi aðgerð sendir prufu tölvupóst á netfangið sem stillt er á í prófílnum þínum.', + 'maint_send_test_email_run' => 'Senda prufu tölvupóst', + 'maint_send_test_email_success' => 'Tölvupóstur sendur á :address', + 'maint_send_test_email_mail_subject' => 'Prufupóstur', + 'maint_send_test_email_mail_greeting' => 'Það virðist virka að senda tölvupóst!', + 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', + 'maint_recycle_bin_open' => 'Opna ruslatunnu', + 'maint_regen_references' => 'Regenerate References', + 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', + 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', + + // Recycle Bin + 'recycle_bin' => 'Ruslatunna', + 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'recycle_bin_deleted_item' => 'Eyddur hlutur', + 'recycle_bin_deleted_parent' => 'Foreldri', + 'recycle_bin_deleted_by' => 'Eytt af', + 'recycle_bin_deleted_at' => 'Eytt þann', + 'recycle_bin_permanently_delete' => 'Eyða varanlega', + 'recycle_bin_restore' => 'Endurheimta', + 'recycle_bin_contents_empty' => 'Ruslatunnan er tóm', + 'recycle_bin_empty' => 'Tæma ruslatunnu', + 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', + 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item from the system, along with any child elements listed below, and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_list' => 'Hlutir til eyðingar', + 'recycle_bin_restore_list' => 'Hlutir til endurheimtar', + 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', + 'recycle_bin_restore_parent' => 'Endurheimta foreldri', + 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', + + // Audit Log + 'audit' => 'Ferilskrá', + 'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'audit_event_filter' => 'Atburðasía', + 'audit_event_filter_no_filter' => 'Engin sía', + 'audit_deleted_item' => 'Eyddur hlutur', + 'audit_deleted_item_name' => 'Nafn :name', + 'audit_table_user' => 'Notandi', + 'audit_table_event' => 'Atburður', + 'audit_table_related' => 'Tengdur hlutur eða lýsing', + 'audit_table_ip' => 'IP tala', + 'audit_table_date' => 'Dagsetning virkni', + 'audit_date_from' => 'Dagsetning frá', + 'audit_date_to' => 'Dagsetning til', + + // Role Settings + 'roles' => 'Hlutverk', + 'role_user_roles' => 'Notanda hlutverk', + 'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.', + 'roles_x_users_assigned' => ':count user assigned|:count users assigned', + 'roles_x_permissions_provided' => ':count permission|:count permissions', + 'roles_assigned_users' => 'Skilgreindir notendur', + 'roles_permissions_provided' => 'Uppgefnar heimildir', + 'role_create' => 'Búa til nýtt hlutverk', + 'role_delete' => 'Eyða hlutverki', + 'role_delete_confirm' => 'This will delete the role with the name \':roleName\'.', + 'role_delete_users_assigned' => 'This role has :userCount users assigned to it. If you would like to migrate the users from this role select a new role below.', + 'role_delete_no_migration' => "Don't migrate users", + 'role_delete_sure' => 'Are you sure you want to delete this role?', + 'role_edit' => 'Breyta hlutverki', + 'role_details' => 'Lýsing á hlutverki', + 'role_name' => 'Nafn hlutverks', + 'role_desc' => 'Stutt lýsing á hlutverki', + 'role_mfa_enforced' => 'Krefst tvöfaldrar auðkenningar', + 'role_external_auth_id' => 'Ytri auðkenningarnúmer', + 'role_system' => 'Réttindastillingar kerfis', + 'role_manage_users' => 'Sýsla með notendur', + 'role_manage_roles' => 'Stýra hlutverkum og réttindum hlutverka', + 'role_manage_entity_permissions' => 'Stýra öllum bóka, kafla og síðu réttindum', + 'role_manage_own_entity_permissions' => 'Stýra réttindum á eigin bókum, köflum og síðum', + 'role_manage_page_templates' => 'Stýra síðu sníðmátum', + 'role_access_api' => 'Access system API', + 'role_manage_settings' => 'Manage app settings', + 'role_export_content' => 'Flytja út efni', + 'role_import_content' => 'Flytja inn efni', + 'role_editor_change' => 'Skipta um ritil síðu', + 'role_notifications' => 'Receive & manage notifications', + 'role_asset' => 'Asset Permissions', + 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', + 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', + 'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.', + 'role_asset_image_view_note' => 'This relates to visibility within the image manager. Actual access of uploaded image files will be dependant upon system image storage option.', + 'role_all' => 'Allt', + 'role_own' => 'Eigin', + 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', + 'role_save' => 'Vista hlutverk', + 'role_users' => 'Notendur í þessu hlutverki', + 'role_users_none' => 'Engir notendur eru eins og er í þessu hlutverki', + + // Users + 'users' => 'Notendur', + 'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.', + 'user_profile' => 'Prófíll notanda', + 'users_add_new' => 'Bæta við nýjum notanda', + 'users_search' => 'Leita að notendum', + 'users_latest_activity' => 'Síðasta virkni', + 'users_details' => 'Notendaupplýsingar', + 'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.', + 'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.', + 'users_role' => 'Hlutverk notenda', + 'users_role_desc' => 'Select which roles this user will be assigned to. If a user is assigned to multiple roles the permissions from those roles will stack and they will receive all abilities of the assigned roles.', + 'users_password' => 'Lykilorð notanda', + 'users_password_desc' => 'Setja lykilorð sem þú notar til að skrá þig inn í hugbúnaðinn. Lykilorðið verður að vera a. m. k 8 stafa langt.', + 'users_send_invite_text' => 'You can choose to send this user an invitation email which allows them to set their own password otherwise you can set their password yourself.', + 'users_send_invite_option' => 'Senda boð á notanda með tölvupósti', + 'users_external_auth_id' => 'Ytra auðkenningar númer', + 'users_external_auth_id_desc' => 'When an external authentication system is in use (such as SAML2, OIDC or LDAP) this is the ID which links this BookStack user to the authentication system account. You can ignore this field if using the default email-based authentication.', + 'users_password_warning' => 'Only fill the below if you would like to change the password for this user.', + 'users_system_public' => 'This user represents any guest users that visit your instance. It cannot be used to log in but is assigned automatically.', + 'users_delete' => 'Eyða notanda', + 'users_delete_named' => 'Eyða notanda :userName', + 'users_delete_warning' => 'This will fully delete this user with the name \':userName\' from the system.', + 'users_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum notanda?', + 'users_migrate_ownership' => 'Færa eignarhald', + 'users_migrate_ownership_desc' => 'Select a user here if you want another user to become the owner of all items currently owned by this user.', + 'users_none_selected' => 'Engin notandi valin', + 'users_edit' => 'Breyta notanda', + 'users_edit_profile' => 'Breyta prófíl', + 'users_avatar' => 'Avatar notanda', + 'users_avatar_desc' => 'Select an image to represent this user. This should be approx 256px square.', + 'users_preferred_language' => 'Valið tungumál', + 'users_preferred_language_desc' => 'This option will change the language used for the user-interface of the application. This will not affect any user-created content.', + 'users_social_accounts' => 'Samfélagsmiðla reikningar', + 'users_social_accounts_desc' => 'View the status of the connected social accounts for this user. Social accounts can be used in addition to the primary authentication system for system access.', + 'users_social_accounts_info' => 'Here you can connect your other accounts for quicker and easier login. Disconnecting an account here does not revoke previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Tengja aðgang', + 'users_social_disconnect' => 'Aftengja aðgang', + 'users_social_status_connected' => 'Tengt', + 'users_social_status_disconnected' => 'Aftengt', + 'users_social_connected' => ':socialAccount account was successfully attached to your profile.', + 'users_social_disconnected' => ':socialAccount account was successfully disconnected from your profile.', + 'users_api_tokens' => 'API tókar', + 'users_api_tokens_desc' => 'Create and manage the access tokens used to authenticate with the BookStack REST API. Permissions for the API are managed via the user that the token belongs to.', + 'users_api_tokens_none' => 'No API tokens have been created for this user', + 'users_api_tokens_create' => 'Búa til tóka', + 'users_api_tokens_expires' => 'Rennur út', + 'users_api_tokens_docs' => 'API Documentation', + 'users_mfa' => 'Multi-Factor Authentication', + 'users_mfa_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.', + 'users_mfa_x_methods' => ':count method configured|:count methods configured', + 'users_mfa_configure' => 'Configure Methods', + + // API Tokens + 'user_api_token_create' => 'Búa til API tóka', + 'user_api_token_name' => 'Nafn', + 'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.', + 'user_api_token_expiry' => 'Rennur út þann', + 'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.', + 'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.', + 'user_api_token' => 'API tóki', + 'user_api_token_id' => 'Númer tóka', + 'user_api_token_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.', + 'user_api_token_secret' => 'Tóka leyndarmál', + 'user_api_token_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.', + 'user_api_token_created' => 'Tóki búinn til :timeAgo', + 'user_api_token_updated' => 'Tóki uppfærður :timeAgo', + 'user_api_token_delete' => 'Eyða tóka', + 'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.', + 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', + + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.', + 'webhooks_x_trigger_events' => ':count trigger event|:count trigger events', + 'webhooks_create' => 'Búa til nýjann Webhook', + 'webhooks_none_created' => 'Engir Webhooks hafa verið búnir til.', + 'webhooks_edit' => 'Breyta Webhook', + 'webhooks_save' => 'Vista Webhook', + 'webhooks_details' => 'Upplýsingar um Webhook', + 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', + 'webhooks_events' => 'Webhook atburðir', + 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', + 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_events_all' => 'Allir kerfis atburðir', + 'webhooks_name' => 'Webhook nafn', + 'webhooks_timeout' => 'Webhook tímamörk (Sekúndur)', + 'webhooks_endpoint' => 'Webhook endapunktur', + 'webhooks_active' => 'Webhook virkur', + 'webhook_events_table_header' => 'Atburðir', + 'webhooks_delete' => 'Eyða Webhook', + 'webhooks_delete_warning' => 'Þessi aðgerð mun eyða þessum Webhook að fullu \':webhookName\',.', + 'webhooks_delete_confirm' => 'Ertu viss um að þú viljir eyða þessum Webhook?', + 'webhooks_format_example' => 'Dæmi um snið á Webhook', + 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + 'webhooks_status' => 'Staða á Webhook', + 'webhooks_last_called' => 'Síðast kallað:', + 'webhooks_last_errored' => 'Síðasta villa kom upp:', + 'webhooks_last_error_message' => 'Síðustu villuskilaboð:', + + // Licensing + 'licenses' => 'Leyfi', + 'licenses_desc' => 'This page details license information for BookStack in addition to the projects & libraries that are used within BookStack. Many projects listed may only be used in a development context.', + 'licenses_bookstack' => 'Bookstack leyfi', + 'licenses_php' => 'PHP kóðasafnsleyfi', + 'licenses_js' => 'Javascript kóðasafnsleyfi', + 'licenses_other' => 'Önnur leyfi', + 'license_details' => 'Upplýsingar um leyfi', + + //! If editing translations files directly please ignore this in all + //! languages apart from en. Content will be auto-copied from en. + //!//////////////////////////////// + 'language_select' => [ + 'en' => 'English', + 'ar' => 'العربية', + 'bg' => 'Bǎlgarski', + 'bs' => 'Bosanski', + 'ca' => 'Català', + 'cs' => 'Česky', + 'cy' => 'Cymraeg', + 'da' => 'Dansk', + 'de' => 'Deutsch (Sie)', + 'de_informal' => 'Deutsch (Du)', + 'el' => 'ελληνικά', + 'es' => 'Español', + 'es_AR' => 'Español Argentina', + 'et' => 'Eesti keel', + 'eu' => 'Euskara', + 'fa' => 'فارسی', + 'fi' => 'Suomi', + 'fr' => 'Français', + 'he' => 'עברית', + 'hr' => 'Hrvatski', + 'hu' => 'Magyar', + 'id' => 'Bahasa Indonesia', + 'it' => 'Italian', + 'ja' => '日本語', + 'ko' => '한국어', + 'lt' => 'Lietuvių Kalba', + 'lv' => 'Latviešu Valoda', + 'nb' => 'Norsk (Bokmål)', + 'nn' => 'Nynorsk', + 'nl' => 'Nederlands', + 'pl' => 'Polski', + 'pt' => 'Português', + 'pt_BR' => 'Português do Brasil', + 'ro' => 'Română', + 'ru' => 'Русский', + 'sk' => 'Slovensky', + 'sl' => 'Slovenščina', + 'sv' => 'Svenska', + 'tr' => 'Türkçe', + 'uk' => 'Українська', + 'uz' => 'O‘zbekcha', + 'vi' => 'Tiếng Việt', + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + ], + //!//////////////////////////////// +]; diff --git a/lang/is/validation.php b/lang/is/validation.php new file mode 100644 index 000000000..9183d27cf --- /dev/null +++ b/lang/is/validation.php @@ -0,0 +1,122 @@ + 'Það verður að samþykkja :attribute.', + 'active_url' => ':attribute Er ekki gilt vistfang.', + 'after' => ':attribute verður að vera dagsetning eftir :date.', + 'alpha' => ':attribute má eingöngu innihalda stafi.', + 'alpha_dash' => ':attribute má eingöngu innihalda stafi, tölustafi, strik og undirstrik.', + 'alpha_num' => ':attribute má eingöngu innihalda stafi og tölustafi.', + 'array' => 'The :attribute must be an array.', + 'backup_codes' => 'Kóðinn sem þú gafst upp er ekki gildur eða hefur þegar verið notaður.', + 'before' => ':attribute verður að vera dagsetning á undan :date.', + 'between' => [ + 'numeric' => ':attribute verður að vera á milli :min og :max.', + 'file' => ':attribute verður að vera á milli :min og :max kílóbæti.', + 'string' => ':attribute verður að vera á milli :min og :max stafir.', + 'array' => ':attribute verður að vera á milli :min og :max fjöldi.', + ], + 'boolean' => ':attribute gildið verður að vera true og false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'email' => 'The :attribute must be a valid email address.', + 'ends_with' => 'The :attribute must end with one of the following: :values', + 'file' => 'The :attribute must be provided as a valid file.', + 'filled' => 'The :attribute field is required.', + 'gt' => [ + 'numeric' => 'The :attribute must be greater than :value.', + 'file' => 'The :attribute must be greater than :value kilobytes.', + 'string' => 'The :attribute must be greater than :value characters.', + 'array' => 'The :attribute must have more than :value items.', + ], + 'gte' => [ + 'numeric' => 'The :attribute must be greater than or equal :value.', + 'file' => 'The :attribute must be greater than or equal :value kilobytes.', + 'string' => 'The :attribute must be greater than or equal :value characters.', + 'array' => 'The :attribute must have :value items or more.', + ], + 'exists' => 'The selected :attribute is invalid.', + 'image' => 'The :attribute must be an image.', + 'image_extension' => 'The :attribute must have a valid & supported image extension.', + 'in' => 'The selected :attribute is invalid.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'ipv4' => 'The :attribute must be a valid IPv4 address.', + 'ipv6' => 'The :attribute must be a valid IPv6 address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'lt' => [ + 'numeric' => 'The :attribute must be less than :value.', + 'file' => 'The :attribute must be less than :value kilobytes.', + 'string' => 'The :attribute must be less than :value characters.', + 'array' => 'The :attribute must have less than :value items.', + ], + 'lte' => [ + 'numeric' => 'The :attribute must be less than or equal :value.', + 'file' => 'The :attribute must be less than or equal :value kilobytes.', + 'string' => 'The :attribute must be less than or equal :value characters.', + 'array' => 'The :attribute must not have more than :value items.', + ], + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute format is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values is present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'safe_url' => 'The provided link may not be safe.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'totp' => 'The provided code is not valid or has expired.', + 'unique' => 'The :attribute has already been taken.', + '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_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.', + + // Custom validation lines + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Staðfestingu á lykilorði er krafist', + ], + ], + + // Custom validation attributes + 'attributes' => [], +]; diff --git a/lang/it/activities.php b/lang/it/activities.php index 61a225185..0747d7784 100644 --- a/lang/it/activities.php +++ b/lang/it/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'ha eliminato un webhook', 'webhook_delete_notification' => 'Webhook eliminato con successo', + // 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' => 'ha creato un utente', 'user_create_notification' => 'Utente creato con successo', diff --git a/lang/it/editor.php b/lang/it/editor.php index f9a6e47ae..11b019ee3 100644 --- a/lang/it/editor.php +++ b/lang/it/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Informazioni sull\'editor', 'about_title' => 'Informazioni sull\'editor di WYSIWYG', 'editor_license' => 'Licenza e copyright dell\'editor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Questo editor è realizzato usando :tinyLink che è fornito sotto la licenza MIT.', 'editor_tiny_license_link' => 'I dettagli del copyright e della licenza di TinyMCE sono disponibili qui.', 'save_continue' => 'Salva pagina e continua', diff --git a/lang/it/entities.php b/lang/it/entities.php index d7542b1ba..22c428ed3 100644 --- a/lang/it/entities.php +++ b/lang/it/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'File PDF', 'export_text' => 'File di testo', 'export_md' => 'File Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Modello di pagina predefinito', 'default_template_explain' => 'Assegna un modello di pagina che sarà usato come contenuto predefinito per tutte le pagine create in questo elemento. Tieni presente che potrà essere utilizzato solo se il creatore della pagina ha accesso alla pagina del modello scelto.', 'default_template_select' => 'Seleziona una pagina modello', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permessi', diff --git a/lang/it/errors.php b/lang/it/errors.php index 5120a6d71..a68da405b 100644 --- a/lang/it/errors.php +++ b/lang/it/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName è offline al momento', 'back_soon' => 'Tornerà presto online.', + // 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.', + '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.', + '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' => 'Nessun token di autorizzazione trovato nella richiesta', 'api_bad_authorization_format' => 'Un token di autorizzazione è stato trovato nella richiesta, ma il formato sembra non corretto', diff --git a/lang/it/settings.php b/lang/it/settings.php index 5d68fd26d..e57baceb4 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Accedere alle API di sistema', 'role_manage_settings' => 'Gestire impostazioni app', 'role_export_content' => 'Esportare contenuto', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Cambiare editor di pagina', 'role_notifications' => 'Ricevere e gestire le notifiche', 'role_asset' => 'Permessi entità', diff --git a/lang/it/validation.php b/lang/it/validation.php index f8f59a3ac..b21e8d932 100644 --- a/lang/it/validation.php +++ b/lang/it/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Il formato :attribute non è valido.', 'uploaded' => 'Il file non può essere caricato. Il server potrebbe non accettare file di questa dimensione.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ja/activities.php b/lang/ja/activities.php index 0e6e8d3ce..f20f9a90d 100644 --- a/lang/ja/activities.php +++ b/lang/ja/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'がWebhookを削除', 'webhook_delete_notification' => 'Webhookを削除しました', + // 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' => 'がユーザを作成', 'user_create_notification' => 'ユーザーを作成しました', diff --git a/lang/ja/editor.php b/lang/ja/editor.php index 22162796c..576e08f14 100644 --- a/lang/ja/editor.php +++ b/lang/ja/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'エディタについて', 'about_title' => 'WYSIWYGエディタについて', 'editor_license' => 'エディタのライセンスと著作権', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'このエディタはMITライセンスの下で提供される:tinyLinkを利用して構築されています。', 'editor_tiny_license_link' => 'TinyMCEの著作権およびライセンスの詳細は、こちらをご覧ください。', 'save_continue' => 'ページを保存して続行', diff --git a/lang/ja/entities.php b/lang/ja/entities.php index 489032835..05e2f33ab 100644 --- a/lang/ja/entities.php +++ b/lang/ja/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF', 'export_text' => 'テキストファイル', 'export_md' => 'Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'デフォルトページテンプレート', 'default_template_explain' => 'このアイテム内に新しいページを作成する際にデフォルトコンテンツとして使用されるページテンプレートを割り当てます。これはページ作成者が選択したテンプレートページへのアクセス権を持つ場合にのみ使用されることに注意してください。', 'default_template_select' => 'テンプレートページを選択', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => '権限', diff --git a/lang/ja/errors.php b/lang/ja/errors.php index 4eba63659..ca001d768 100644 --- a/lang/ja/errors.php +++ b/lang/ja/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appNameは現在停止しています', 'back_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.', + '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.', + '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' => 'リクエストに認証トークンが見つかりません', 'api_bad_authorization_format' => 'リクエストに認証トークンが見つかりましたが、形式が正しくないようです', diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 218eecf8c..d73f12cb2 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'システムのAPIへのアクセス', 'role_manage_settings' => 'アプリケーション設定の管理', 'role_export_content' => 'コンテンツのエクスポート', + 'role_import_content' => 'Import content', 'role_editor_change' => 'ページエディタの変更', 'role_notifications' => '通知の受信と管理', 'role_asset' => 'アセット権限', diff --git a/lang/ja/validation.php b/lang/ja/validation.php index 96ae7dfff..110bcb012 100644 --- a/lang/ja/validation.php +++ b/lang/ja/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attributeのフォーマットは不正です。', 'uploaded' => 'ファイルをアップロードできませんでした。サーバーがこのサイズのファイルを受け付けていない可能性があります。', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ka/activities.php b/lang/ka/activities.php index 6c7af4429..4cc9fbcb6 100644 --- a/lang/ka/activities.php +++ b/lang/ka/activities.php @@ -84,6 +84,14 @@ return [ '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/ka/editor.php b/lang/ka/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/ka/editor.php +++ b/lang/ka/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/ka/entities.php b/lang/ka/entities.php index 35e6f050b..26a563a7e 100644 --- a/lang/ka/entities.php +++ b/lang/ka/entities.php @@ -39,9 +39,30 @@ return [ '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/ka/errors.php b/lang/ka/errors.php index 9c40aa9ed..9d7383796 100644 --- a/lang/ka/errors.php +++ b/lang/ka/errors.php @@ -105,6 +105,18 @@ return [ '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.', + '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.', + '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', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/ka/settings.php b/lang/ka/settings.php index 5427cb941..c0b6b692a 100644 --- a/lang/ka/settings.php +++ b/lang/ka/settings.php @@ -162,6 +162,7 @@ return [ '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/lang/ka/validation.php b/lang/ka/validation.php index 2a676c7c4..d9b982d1e 100644 --- a/lang/ka/validation.php +++ b/lang/ka/validation.php @@ -105,6 +105,11 @@ return [ '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_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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ko/activities.php b/lang/ko/activities.php index bcf347956..356568d1f 100644 --- a/lang/ko/activities.php +++ b/lang/ko/activities.php @@ -77,13 +77,21 @@ return [ 'maintenance_action_run' => '유지 관리 작업 실행', // Webhooks - 'webhook_create' => '웹 훅 만들기', + 'webhook_create' => '웹 훅 생성', 'webhook_create_notification' => '웹 훅 생성함', 'webhook_update' => '웹 훅 수정하기', 'webhook_update_notification' => '웹훅 설정이 수정되었습니다.', 'webhook_delete' => '웹 훅 지우기', 'webhook_delete_notification' => '웹 훅 삭제함', + // 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' => '사용자 생성', 'user_create_notification' => '사용자 생성 성공', diff --git a/lang/ko/common.php b/lang/ko/common.php index 246a3aebc..b19b0d5cd 100644 --- a/lang/ko/common.php +++ b/lang/ko/common.php @@ -71,7 +71,7 @@ return [ 'back_to_top' => '맨 위로', 'skip_to_main_content' => '메인 항목으로', 'toggle_details' => '내용 보기', - 'toggle_thumbnails' => '섬네일 보기', + 'toggle_thumbnails' => '썸네일 보기', 'details' => '정보', 'grid_view' => '격자 보기', 'list_view' => '목록 보기', @@ -101,7 +101,7 @@ return [ // Email Content 'email_action_help' => ':actionText를 클릭할 수 없을 때는 웹 브라우저에서 다음 링크로 접속할 수 있습니다.', - 'email_rights' => 'All rights reserved.', + 'email_rights' => '모든 권리는 보호됩니다.', // Footer Link Options // Not directly used but available for convenience to users. diff --git a/lang/ko/components.php b/lang/ko/components.php index c81497e03..cbda4d159 100644 --- a/lang/ko/components.php +++ b/lang/ko/components.php @@ -12,7 +12,7 @@ return [ 'image_intro' => '여기에서 이전에 시스템에 업로드한 이미지를 선택하고 관리할 수 있습니다.', 'image_intro_upload' => '이미지 파일을 이 창으로 끌어다 놓거나 위의 \'이미지 업로드\' 버튼을 사용하여 새 이미지를 업로드합니다.', 'image_all' => '모든 이미지', - 'image_all_title' => '모든 이미지', + 'image_all_title' => '모든 이미지 보기', 'image_book_title' => '이 책에서 쓰고 있는 이미지', 'image_page_title' => '이 문서에서 쓰고 있는 이미지', 'image_search_hint' => '이미지 이름 검색', @@ -30,8 +30,8 @@ return [ 'images_deleted' => '이미지 삭제함', 'image_preview' => '이미지 미리 보기', 'image_upload_success' => '이미지 올림', - 'image_update_success' => '이미지 정보 수정함', - 'image_delete_success' => '이미지 삭제함', + 'image_update_success' => '이미지 정보가 수정되었습니다.', + 'image_delete_success' => '이미지가 삭제되었습니다.', 'image_replace' => '이미지 교체', 'image_replace_success' => '이미지 파일 업데이트 성공', 'image_rebuild_thumbs' => '사이즈 변경 재생성하기', diff --git a/lang/ko/editor.php b/lang/ko/editor.php index 485ab79cd..689241016 100644 --- a/lang/ko/editor.php +++ b/lang/ko/editor.php @@ -163,6 +163,8 @@ return [ 'about' => '이 편집기에 대하여', 'about_title' => 'WYSIWYG 편집기에 대하여', 'editor_license' => '편집기 라이선스 & 저작권', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => '전체 라이센스 세부 사항은 여기에서 확인할 수 있습니다.', 'editor_tiny_license' => '이 편집기는 MIT 라이선스에 따라 제공되는 :tinyLink를 사용하여 제작되었습니다.', 'editor_tiny_license_link' => 'TinyMCE의 저작권 및 라이선스 세부 정보는 여기에서 확인할 수 있습니다.', 'save_continue' => '저장하고 계속하기', diff --git a/lang/ko/entities.php b/lang/ko/entities.php index 4afd174f5..4fbbe3766 100644 --- a/lang/ko/entities.php +++ b/lang/ko/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF 파일', 'export_text' => '일반 텍스트 파일', 'export_md' => '마크다운 파일', + 'export_zip' => 'Portable ZIP', 'default_template' => '기본 페이지 템플릿', 'default_template_explain' => '이 항목 내에서 생성되는 모든 페이지의 기본 콘텐츠로 사용할 페이지 템플릿을 지정합니다. 페이지 작성자가 선택한 템플릿 페이지를 볼 수 있는 권한이 있는 경우에만 이 항목이 사용된다는 점을 유의하세요.', 'default_template_select' => '템플릿 페이지 선택', + 'import' => 'Import', + 'import_validate' => 'Validate Import', + 'import_desc' => '같은 인스턴스나 다른 인스턴스에서 휴대용 zip 내보내기를 사용하여 책, 장 및 페이지를 가져옵니다. 진행하려면 ZIP 파일을 선택합니다. 파일을 업로드하고 검증한 후 다음 보기에서 가져오기를 구성하고 확인할 수 있습니다.', + '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_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', + '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' => '업로드된 ZIP 파일이 삭제되며, 실행 취소할 수 없습니다.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => '권한', @@ -130,7 +151,7 @@ return [ 'books_delete' => '책 삭제하기', 'books_delete_named' => ':bookName(을)를 지웁니다.', 'books_delete_explain' => ':bookName에 있는 모든 챕터와 문서도 지웁니다.', - 'books_delete_confirmation' => '이 책을 지울 건가요?', + 'books_delete_confirmation' => '이 책을 지우시겠습니까?', 'books_edit' => '책 바꾸기', 'books_edit_named' => ':bookName(을)를 바꿉니다.', 'books_form_book_name' => '책 이름', @@ -139,7 +160,7 @@ return [ 'books_permissions_updated' => '권한 저장함', 'books_empty_contents' => '이 책에 챕터나 문서가 없습니다.', 'books_empty_create_page' => '문서 만들기', - 'books_empty_sort_current_book' => '읽고 있는 책 정렬', + 'books_empty_sort_current_book' => '현재 책 정렬', 'books_empty_add_chapter' => '챕터 만들기', 'books_permissions_active' => '책 권한 허용함', 'books_search_this' => '이 책에서 검색', @@ -224,7 +245,7 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_stable' => '(Stable Content)', 'pages_edit_switch_to_wysiwyg' => 'WYSIWYG 편집기로 전환', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg' => '새 위지윅 편집기로 변경', 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', 'pages_edit_set_changelog' => '수정본 설명', 'pages_edit_enter_changelog_desc' => '수정본 설명', @@ -287,7 +308,7 @@ return [ 'pages_initial_name' => '제목 없음', 'pages_editing_draft_notification' => ':timeDiff에 초안 문서입니다.', 'pages_draft_edited_notification' => '최근에 수정한 문서이기 때문에 초안 문서를 폐기하는 편이 좋습니다.', - 'pages_draft_page_changed_since_creation' => '최근에 수정한 문서이기 때문에 초안 문서를 폐기하는 편이 좋습니다.', + 'pages_draft_page_changed_since_creation' => '최근에 수정한 문서이기 때문에 임시 저장문서를 폐기하는 편이 좋습니다.', 'pages_draft_edit_active' => [ 'start_a' => ':count명이 이 문서를 수정하고 있습니다.', 'start_b' => ':userName이 이 문서를 수정하고 있습니다.', @@ -302,7 +323,7 @@ return [ // Editor Sidebar 'toggle_sidebar' => '사이드바 토글', - 'page_tags' => '문서 꼬리표', + 'page_tags' => '문서 태그', 'chapter_tags' => '챕터 꼬리표', 'book_tags' => '책 꼬리표', 'shelf_tags' => '책꽂이 꼬리표', @@ -317,7 +338,7 @@ return [ 'tags_usages' => '모든 꼬리표', 'tags_assigned_pages' => '문서에 꼬리표 지정함', 'tags_assigned_chapters' => '챕터에 꼬리표 지정함', - 'tags_assigned_books' => '책에 꼬리표 지정함', + 'tags_assigned_books' => '책에 태그 지정함', 'tags_assigned_shelves' => '책꽂이에 꼬리표 지정함', 'tags_x_unique_values' => ':count 중복 없는 값', 'tags_all_values' => '모든 값', @@ -351,7 +372,7 @@ return [ 'attachments_file_updated' => '파일 바꿈', 'attachments_link_attached' => '링크 첨부함', 'templates' => '템플릿', - 'templates_set_as_template' => '템플릿', + 'templates_set_as_template' => '현재 페이지는 템플릿용 페이지 입니다.', 'templates_explain_set_as_template' => '템플릿은 보기 권한만 있어도 문서에 쓸 수 있습니다.', 'templates_replace_content' => '문서 대체', 'templates_append_content' => '문서 앞에 추가', @@ -433,7 +454,7 @@ return [ 'watch_detail_updates' => '새 페이지 및 업데이트 보기', 'watch_detail_comments' => '새 페이지, 업데이트 및 댓글 보기', 'watch_detail_parent_book' => '상위 책을 통해 보기', - 'watch_detail_parent_book_ignore' => '페어런트 북을 통한 무시하기', + 'watch_detail_parent_book_ignore' => '상위 책을 통한 무시하기', 'watch_detail_parent_chapter' => '상위 챕터를 통해 보기', 'watch_detail_parent_chapter_ignore' => '상위 챕터를 통해 무시하기', ]; diff --git a/lang/ko/errors.php b/lang/ko/errors.php index 7f2d8bb6a..f5ba13efa 100644 --- a/lang/ko/errors.php +++ b/lang/ko/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName에 문제가 생겼습니다.', 'back_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.', + '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.', + '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' => '요청에서 인증 토큰을 찾을 수 없습니다.', 'api_bad_authorization_format' => '요청에서 인증 토큰을 찾았으나 형식에 문제가 있습니다.', diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 3a24e21a0..7277891d5 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => '시스템 접근 API', 'role_manage_settings' => '사이트 설정 관리', 'role_export_content' => '항목 내보내기', + 'role_import_content' => 'Import content', 'role_editor_change' => '페이지 편집기 변경', 'role_notifications' => '알림 수신 및 관리', 'role_asset' => '권한 항목', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index e533429ee..7a4d228ce 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute(은)는 유효하지 않은 형식입니다.', 'uploaded' => '파일 크기가 서버에서 허용하는 수치를 넘습니다.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/lt/activities.php b/lang/lt/activities.php index 9a240d83c..2c4165195 100644 --- a/lang/lt/activities.php +++ b/lang/lt/activities.php @@ -84,6 +84,14 @@ return [ '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/lt/editor.php b/lang/lt/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/lt/editor.php +++ b/lang/lt/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/lt/entities.php b/lang/lt/entities.php index 17601aa16..91d4cedc4 100644 --- a/lang/lt/entities.php +++ b/lang/lt/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF failas', 'export_text' => 'Paprastas failo tekstas', '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Leidimai', diff --git a/lang/lt/errors.php b/lang/lt/errors.php index 2209a00d7..392e99a51 100644 --- a/lang/lt/errors.php +++ b/lang/lt/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName dabar yra apačioje', 'back_soon' => 'Tai sugrįž greitai', + // 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.', + '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.', + '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' => 'Užklausoje nerastas įgaliojimo prieigos raktas', 'api_bad_authorization_format' => 'Užklausoje rastas prieigos raktas, tačiau formatas yra neteisingas', diff --git a/lang/lt/settings.php b/lang/lt/settings.php index 3692bd241..ddd2a7022 100644 --- a/lang/lt/settings.php +++ b/lang/lt/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Gauti prieigą prie sistemos API', 'role_manage_settings' => 'Tvarkyti programos nustatymus', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Nuosavybės leidimai', diff --git a/lang/lt/validation.php b/lang/lt/validation.php index 08470cf22..92de23004 100644 --- a/lang/lt/validation.php +++ b/lang/lt/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute formatas yra klaidingas.', 'uploaded' => 'Šis failas negali būti įkeltas. Serveris gali nepriimti tokio dydžio failų.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/lv/activities.php b/lang/lv/activities.php index f7db090d8..2f9b49f8e 100644 --- a/lang/lv/activities.php +++ b/lang/lv/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'izdzēsa webhook', 'webhook_delete_notification' => 'Webhook veiksmīgi izdzēsts', + // 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' => 'izveidoja lietotāju', 'user_create_notification' => 'Lietotājs veiksmīgi izveidots', diff --git a/lang/lv/editor.php b/lang/lv/editor.php index 5443e505d..1bdf4476d 100644 --- a/lang/lv/editor.php +++ b/lang/lv/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Par redaktoru', 'about_title' => 'Par WYSIWYG redaktoru', 'editor_license' => 'Redaktora licence un autortiesības', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Šis redaktors ir izveidots, izmantojot :tinyLink, kas ir publicēts ar MIT licenci.', 'editor_tiny_license_link' => 'TinyMCE autortiesības un licences detaļas var atrast šeit.', 'save_continue' => 'Saglabāt lapu un turpināt', diff --git a/lang/lv/entities.php b/lang/lv/entities.php index 771fffd44..f5d9a2eeb 100644 --- a/lang/lv/entities.php +++ b/lang/lv/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF fails', 'export_text' => 'Vienkāršs teksta fails', 'export_md' => 'Markdown fails', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Noklusētā lapas sagatave', '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' => 'Izvēlēt sagataves lapu', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Atļaujas', diff --git a/lang/lv/errors.php b/lang/lv/errors.php index e705da6a0..40d755e61 100644 --- a/lang/lv/errors.php +++ b/lang/lv/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName pagaidām nav pieejams', 'back_soon' => 'Drīz būs atkal pieejams.', + // 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.', + '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.', + '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' => 'Pieprasījumā nav atrasts autorizācijas žetons', 'api_bad_authorization_format' => 'Pieprasījumā atrasts autorizācijas žetons, taču tā formāts nav pareizs', diff --git a/lang/lv/settings.php b/lang/lv/settings.php index a94b45b3e..3fca3050a 100644 --- a/lang/lv/settings.php +++ b/lang/lv/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Piekļūt sistēmas API', 'role_manage_settings' => 'Pārvaldīt iestatījumus', 'role_export_content' => 'Eksportēt saturu', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Mainīt lapu redaktoru', 'role_notifications' => 'Saņemt un pārvaldīt paziņojumus', 'role_asset' => 'Resursa piekļuves tiesības', diff --git a/lang/lv/validation.php b/lang/lv/validation.php index 64a0dece3..5b714af86 100644 --- a/lang/lv/validation.php +++ b/lang/lv/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute formāts nav derīgs.', 'uploaded' => 'Fails netika ielādēts. Serveris nevar pieņemt šāda izmēra failus.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/nb/activities.php b/lang/nb/activities.php index 04ab09c84..3f8ee88df 100644 --- a/lang/nb/activities.php +++ b/lang/nb/activities.php @@ -85,6 +85,14 @@ return [ 'webhook_delete' => 'slettet webhook', 'webhook_delete_notification' => 'Webhook ble slettet', + // Imports + 'import_create' => 'import opprettet', + 'import_create_notification' => 'Importen ble opplastet', + 'import_run' => 'oppdatert import', + 'import_run_notification' => 'Innhold importert', + 'import_delete' => 'import slettet', + 'import_delete_notification' => 'Importering ble slettet', + // Users 'user_create' => 'opprettet bruker', 'user_create_notification' => 'Bruker ble opprettet', diff --git a/lang/nb/common.php b/lang/nb/common.php index 0e9e19b6e..1214e81c5 100644 --- a/lang/nb/common.php +++ b/lang/nb/common.php @@ -109,5 +109,5 @@ return [ 'terms_of_service' => 'Bruksvilkår', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => 'Søk :appName', ]; diff --git a/lang/nb/editor.php b/lang/nb/editor.php index 759f18036..e128717cd 100644 --- a/lang/nb/editor.php +++ b/lang/nb/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Om tekstredigeringsprogrammet', 'about_title' => 'Om HDSEHDF-tekstredigeringsprogrammet', 'editor_license' => 'Tekstbehandlerlisens og opphavsrett', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Denne tekstredigereren er laget med :tinyLink som er lisensiert under MIT.', 'editor_tiny_license_link' => 'Informasjon om opphavsrett og lisens for TinyMCE finnes her.', 'save_continue' => 'Lagre side og fortsett', diff --git a/lang/nb/entities.php b/lang/nb/entities.php index a28f15fa6..0d2491eaf 100644 --- a/lang/nb/entities.php +++ b/lang/nb/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF Fil', 'export_text' => 'Tekstfil', 'export_md' => 'Markdownfil', + 'export_zip' => 'Flyttbar ZIP', 'default_template' => 'Standard sidemal', 'default_template_explain' => 'Tildel en sidemal som vil bli brukt som standardinnhold for alle nye sider i denne boken. Husk dette vil kun bli brukt hvis sideskaperen har tilgang til den valgte malsiden.', 'default_template_select' => 'Velg en malside', + 'import' => 'Import', + 'import_validate' => 'Valider Import', + 'import_desc' => 'Importer bøker, kapitler & sider ved å bruke en flyttbar zip-eksport fra samme eller en annen forekomst. Velg en ZIP-fil for å fortsette. Når filen har blitt lastet opp og validert vil du kunne konfigurere & bekrefte importen i neste visning.', + 'import_zip_select' => 'Velg ZIP-filen som skal lastes opp', + 'import_zip_validation_errors' => 'Feil ble funnet under validering av den angitte ZIP-filen:', + 'import_pending' => 'Venter på import', + 'import_pending_none' => 'Ingen importer er startet.', + 'import_continue' => 'Fortsett import', + 'import_continue_desc' => 'Gjennomgå innholdet på grunn av at det importeres fra den opplastede ZIP-filen. Når klar, kjøre importen for å legge til innholdet i dette systemet. Den opplastede ZIP-importfilen vil automatisk bli fjernet ved vellykket import.', + 'import_details' => 'Importer detaljer', + 'import_run' => 'Kjør Import', + 'import_size' => ':size Import ZIP størrelse', + 'import_uploaded_at' => 'Opplastet :relativeTime', + 'import_uploaded_by' => 'Lastet opp av', + 'import_location' => 'Import posisjon', + 'import_location_desc' => 'Velg en mållokasjon for ditt importerte innhold. Du vil trenge relevante tillatelser for å opprette innenfor den posisjonen du velger.', + 'import_delete_confirm' => 'Er du sikker på at du vil slette denne importen?', + 'import_delete_desc' => 'Dette vil slette den opplastede importen av ZIP-filen og kan ikke angres.', + 'import_errors' => 'Import feil', + 'import_errors_desc' => 'Feil oppstod under importforsøket:', // Permissions and restrictions 'permissions' => 'Tilganger', @@ -224,8 +245,8 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(Renset innhold)', 'pages_edit_switch_to_markdown_stable' => '(Urørt innhold)', 'pages_edit_switch_to_wysiwyg' => 'Bytt til WYSIWYG tekstredigering', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => 'Bytt til ny WYSIWYG', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(I Alpha Testing)', 'pages_edit_set_changelog' => 'Angi endringslogg', 'pages_edit_enter_changelog_desc' => 'Gi en kort beskrivelse av endringene dine', 'pages_edit_enter_changelog' => 'Se endringslogg', diff --git a/lang/nb/errors.php b/lang/nb/errors.php index 379483626..400681b10 100644 --- a/lang/nb/errors.php +++ b/lang/nb/errors.php @@ -78,7 +78,7 @@ return [ // Users 'users_cannot_delete_only_admin' => 'Du kan ikke slette den eneste administratoren', 'users_cannot_delete_guest' => 'Du kan ikke slette gjestebrukeren (Du kan deaktivere offentlig visning istede)', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => 'Kunne ikke opprette bruker fordi invitasjons e-post ikke kunne sendes', // Roles 'role_cannot_be_edited' => 'Denne rollen kan ikke endres', @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName er nede for øyeblikket', 'back_soon' => 'Den vil snart komme tilbake.', + // Import + 'import_zip_cant_read' => 'Kunne ikke lese ZIP-filen.', + 'import_zip_cant_decode_data' => 'Kunne ikke finne og dekode ZIP data.json innhold.', + 'import_zip_no_data' => 'ZIP-fildata har ingen forventet bok, kapittel eller sideinnhold.', + 'import_validation_failed' => 'Import av ZIP feilet i å validere med feil:', + 'import_zip_failed_notification' => 'Kunne ikke importere ZIP-fil.', + 'import_perms_books' => 'Du mangler nødvendige tillatelser for å lage bøker.', + 'import_perms_chapters' => 'Du mangler de nødvendige tillatelsene for å opprette kapittel.', + 'import_perms_pages' => 'Du mangler nødvendige tillatelser for å opprette sider.', + 'import_perms_images' => 'Du mangler de nødvendige tillatelsene for å opprette bilder.', + 'import_perms_attachments' => 'Du mangler nødvendig tillatelse for å opprette vedlegg.', + // API errors 'api_no_authorization_found' => 'Ingen autorisasjonstoken ble funnet på forespørselen', 'api_bad_authorization_format' => 'Det ble funnet et autorisasjonstoken på forespørselen, men formatet virket feil', diff --git a/lang/nb/settings.php b/lang/nb/settings.php index 539dc1902..a39f6ae1b 100644 --- a/lang/nb/settings.php +++ b/lang/nb/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Systemtilgang API', 'role_manage_settings' => 'Behandle applikasjonsinnstillinger', 'role_export_content' => 'Eksporter innhold', + 'role_import_content' => 'Import innhold', 'role_editor_change' => 'Endre sideredigering', 'role_notifications' => 'Motta og administrere varslinger', 'role_asset' => 'Eiendomstillatelser', diff --git a/lang/nb/validation.php b/lang/nb/validation.php index 7e3784e30..a156da8f7 100644 --- a/lang/nb/validation.php +++ b/lang/nb/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute format er ugyldig.', 'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.', + 'zip_file' => 'Attributtet :attribute må henvises til en fil i ZIP.', + 'zip_file_mime' => 'Attributtet :attribute må referere en fil av typen :validTypes, som ble funnet :foundType.', + 'zip_model_expected' => 'Data objekt forventet, men ":type" funnet.', + 'zip_unique' => 'Attributtet :attribute må være unikt for objekttypen i ZIP.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/nl/activities.php b/lang/nl/activities.php index 52a8e3e37..88563ba87 100644 --- a/lang/nl/activities.php +++ b/lang/nl/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'verwijderde webhook', 'webhook_delete_notification' => 'Webhook succesvol verwijderd', + // Imports + 'import_create' => 'maakte import', + 'import_create_notification' => 'Import succesvol geüpload', + 'import_run' => 'wijzigde import', + 'import_run_notification' => 'Inhoud succesvol geïmporteerd', + 'import_delete' => 'verwijderde import', + 'import_delete_notification' => 'Import succesvol verwijderd', + // Users 'user_create' => 'maakte gebruiker aan', 'user_create_notification' => 'Gebruiker succesvol aangemaakt', diff --git a/lang/nl/editor.php b/lang/nl/editor.php index 89b2e7bea..d82dd8601 100644 --- a/lang/nl/editor.php +++ b/lang/nl/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Over de bewerker', 'about_title' => 'Over de WYSIWYG Bewerker', 'editor_license' => 'Bewerker Licentie & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Deze editor is gemaakt met behulp van :tinyLink welke is verstrekt onder de MIT-licentie.', 'editor_tiny_license_link' => 'De copyright- en licentiegegevens van TinyMCE vindt u hier.', 'save_continue' => 'Pagina opslaan en verdergaan', diff --git a/lang/nl/entities.php b/lang/nl/entities.php index 36955c8e1..57967f9f1 100644 --- a/lang/nl/entities.php +++ b/lang/nl/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF bestand', 'export_text' => 'Normaal tekstbestand', 'export_md' => 'Markdown bestand', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Standaard Paginasjabloon', 'default_template_explain' => 'Ken een paginasjabloon toe die zal worden gebruikt als de standaardinhoud voor alle pagina\'s die binnen dit item worden aangemaakt. Houd er rekening mee dat dit alleen zal worden gebruikt als de paginamaker leesrechten heeft voor de gekozen sjabloonpagina.', 'default_template_select' => 'Selecteer een sjabloonpagina', + 'import' => 'Import', + 'import_validate' => 'Valideer Import', + 'import_desc' => 'Importeer boeken, hoofdstukken & pagina\'s met een portable Zip-export van dezelfde, of een andere omgeving. Selecteer een Zip-bestand om door te gaan. Nadat het bestand is geüpload en gecontroleerd kunt u de import configureren en doorvoeren in de volgende weergave.', + 'import_zip_select' => 'Selecteer een Zip-bestand om te uploaden', + 'import_zip_validation_errors' => 'Er zijn fouten gevonden tijdens het controleren van het Zip-bestand:', + 'import_pending' => 'Wachtende Imports', + 'import_pending_none' => 'Er zijn geen imports gestart.', + 'import_continue' => 'Importeren Voortzetten', + 'import_continue_desc' => 'Controleer de inhoud die gaat worden geïmporteerd vanuit get geüploade Zip-bestand. Voer de import door om de inhoud toe te voegen aan dit systeem. Bij een succesvolle import zal het geüploade Zip-bestand automatisch verwijderd worden.', + 'import_details' => 'Import Details', + 'import_run' => 'Import Doorvoeren', + 'import_size' => ':size Import Zip Grootte', + 'import_uploaded_at' => 'Geüpload :relativeTime', + 'import_uploaded_by' => 'Geüpload door', + 'import_location' => 'Importlocatie', + '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' => 'Importeerfouten', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Machtigingen', diff --git a/lang/nl/errors.php b/lang/nl/errors.php index 0c83cb7f3..07900fc6b 100644 --- a/lang/nl/errors.php +++ b/lang/nl/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName is nu niet beschikbaar', 'back_soon' => 'Komt snel weer online.', + // Import + 'import_zip_cant_read' => 'Kon het Zip-bestand niet lezen.', + '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' => 'Importeren van het Zip-bestand is mislukt.', + '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' => 'Geen autorisatie token gevonden', 'api_bad_authorization_format' => 'Een autorisatie token is gevonden, maar het formaat schijnt onjuist te zijn', diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 1d0b14b5c..646eb2ba9 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Ga naar systeem API', 'role_manage_settings' => 'Beheer app instellingen', 'role_export_content' => 'Exporteer inhoud', + 'role_import_content' => 'Importeer inhoud', 'role_editor_change' => 'Wijzig pagina bewerker', 'role_notifications' => 'Meldingen ontvangen & beheren', 'role_asset' => 'Asset Machtigingen', diff --git a/lang/nl/validation.php b/lang/nl/validation.php index 378ec6a91..52b1a2265 100644 --- a/lang/nl/validation.php +++ b/lang/nl/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute formaat is ongeldig.', 'uploaded' => 'Het bestand kon niet worden geüpload. De server accepteert mogelijk geen bestanden van deze grootte.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/nn/activities.php b/lang/nn/activities.php index 73182e0ba..a522d10e7 100644 --- a/lang/nn/activities.php +++ b/lang/nn/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'sletta webhook', 'webhook_delete_notification' => 'Webhook vart sletta', + // 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' => 'oppretta brukar', 'user_create_notification' => 'Brukar vart oppretta', diff --git a/lang/nn/editor.php b/lang/nn/editor.php index 7998bfadb..6ac0a92af 100644 --- a/lang/nn/editor.php +++ b/lang/nn/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Om tekstredigeringsprogrammet', 'about_title' => 'Om HDSEHDF-tekstredigeringsprogrammet', 'editor_license' => 'Tekstbehandlerlisens og opphavsrett', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Denne tekstredigereren er laget med :tinyLink som er lisensiert under MIT.', 'editor_tiny_license_link' => 'Informasjon om opphavsrett og lisens for TinyMCE finnes her.', 'save_continue' => 'Lagre side og fortsett', diff --git a/lang/nn/entities.php b/lang/nn/entities.php index 790c6ec97..b2ae35269 100644 --- a/lang/nn/entities.php +++ b/lang/nn/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF-fil', 'export_text' => 'Tekstfil', 'export_md' => 'Markdownfil', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Tilgongar', diff --git a/lang/nn/errors.php b/lang/nn/errors.php index 7be55a091..0619c4682 100644 --- a/lang/nn/errors.php +++ b/lang/nn/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName er nede for øyeblikket', 'back_soon' => 'Den vil snart komme tilbake.', + // 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.', + '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.', + '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' => 'Ingen autorisasjonstoken ble funnet på forespørselen', 'api_bad_authorization_format' => 'Det ble funnet et autorisasjonstoken på forespørselen, men formatet virket feil', diff --git a/lang/nn/settings.php b/lang/nn/settings.php index 9eefaf3bb..ed9cd937a 100644 --- a/lang/nn/settings.php +++ b/lang/nn/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Systemtilgang API', 'role_manage_settings' => 'Behandle applikasjonsinnstillinger', 'role_export_content' => 'Eksporter innhold', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Endre sideredigering', 'role_notifications' => 'Motta og administrere varslinger', 'role_asset' => 'Eiendomstillatelser', diff --git a/lang/nn/validation.php b/lang/nn/validation.php index 7e3784e30..a24ebd171 100644 --- a/lang/nn/validation.php +++ b/lang/nn/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute format er ugyldig.', 'uploaded' => 'kunne ikke lastes opp, tjeneren støtter ikke filer av denne størrelsen.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/pl/activities.php b/lang/pl/activities.php index 25f9e23c6..4e209c99c 100644 --- a/lang/pl/activities.php +++ b/lang/pl/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'usunął webhook', 'webhook_delete_notification' => 'Webhook usunięty pomyślnie', + // 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' => 'utworzył użytkownika', 'user_create_notification' => 'Użytkownik utworzony pomyślnie', diff --git a/lang/pl/editor.php b/lang/pl/editor.php index f4ed4b5f2..ac7d57a5d 100644 --- a/lang/pl/editor.php +++ b/lang/pl/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'O edytorze', 'about_title' => 'O edytorze WYSIWYG', 'editor_license' => 'Licencja edytora i prawa autorskie', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ten edytor jest zbudowany przy użyciu :tinyLink, który jest udostępniany na licencji MIT.', 'editor_tiny_license_link' => 'Szczegóły dotyczące praw autorskich i licencji TinyMCE można znaleźć tutaj.', 'save_continue' => 'Zapisz stronę i kontynuuj', diff --git a/lang/pl/entities.php b/lang/pl/entities.php index f0ff5a251..537b15d38 100644 --- a/lang/pl/entities.php +++ b/lang/pl/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Plik PDF', 'export_text' => 'Plik tekstowy', 'export_md' => 'Pliki Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Domyślny szablon strony', 'default_template_explain' => 'Przypisz szablon strony, który będzie używany jako domyślna zawartość dla wszystkich stron utworzonych w tym elemencie. Pamiętaj, że będzie to używane tylko wtedy, gdy twórca strony ma dostęp do wybranej strony szablonu.', 'default_template_select' => 'Wybierz stronę szablonu', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Uprawnienia', diff --git a/lang/pl/errors.php b/lang/pl/errors.php index 71f592ad6..e6ad2093f 100644 --- a/lang/pl/errors.php +++ b/lang/pl/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName jest aktualnie wyłączona', 'back_soon' => 'Niedługo zostanie uruchomiona ponownie.', + // 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.', + '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.', + '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' => 'Nie znaleziono tokenu autoryzacji dla żądania', 'api_bad_authorization_format' => 'Token autoryzacji został znaleziony w żądaniu, ale format okazał się nieprawidłowy', diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 77c25ffa9..811398ec4 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Dostęp do systemowego API', 'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji', 'role_export_content' => 'Eksportuj zawartość', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Zmień edytor strony', 'role_notifications' => 'Odbieranie i zarządzanie powiadomieniami', 'role_asset' => 'Zarządzanie zasobami', diff --git a/lang/pl/validation.php b/lang/pl/validation.php index 935c634df..f20a66dfb 100644 --- a/lang/pl/validation.php +++ b/lang/pl/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Format :attribute jest nieprawidłowy.', 'uploaded' => 'Plik nie może zostać wysłany. Serwer nie akceptuje plików o takim rozmiarze.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/pt/activities.php b/lang/pt/activities.php index 089613b8f..96d950554 100644 --- a/lang/pt/activities.php +++ b/lang/pt/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'eliminar webhook', 'webhook_delete_notification' => 'Webhook criado com sucesso', + // 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' => 'utilizador criado', 'user_create_notification' => 'Utilizador criado com sucesso', diff --git a/lang/pt/editor.php b/lang/pt/editor.php index c3c4975a7..b25b319f0 100644 --- a/lang/pt/editor.php +++ b/lang/pt/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Sobre o editor', 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Editor da licença de direitos autorais', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor foi criado com :tinyLink que é fornecido sob a licença MIT.', 'editor_tiny_license_link' => 'Os dados relativos aos direitos de autor e à licença do TinyMCE podem ser encontrados aqui.', 'save_continue' => 'Salvar página e continuar', diff --git a/lang/pt/entities.php b/lang/pt/entities.php index 792f80056..93d1a38cf 100644 --- a/lang/pt/entities.php +++ b/lang/pt/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Arquivo PDF', 'export_text' => 'Arquivo Texto', 'export_md' => 'Ficheiro Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissões', diff --git a/lang/pt/errors.php b/lang/pt/errors.php index bed2bf5b7..b337005e1 100644 --- a/lang/pt/errors.php +++ b/lang/pt/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName está fora do ar de momento', 'back_soon' => 'Voltaremos em breve.', + // 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.', + '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.', + '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' => 'Nenhum token de autorização encontrado na requisição', 'api_bad_authorization_format' => 'Um token de autorização foi encontrado na requisição, mas o formato parece incorreto', diff --git a/lang/pt/settings.php b/lang/pt/settings.php index 8664884c0..935fd30ce 100644 --- a/lang/pt/settings.php +++ b/lang/pt/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Aceder à API do sistema', 'role_manage_settings' => 'Gerir as configurações da aplicação', 'role_export_content' => 'Exportar conteúdo', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Alterar editor de página', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Permissões de Ativos', diff --git a/lang/pt/validation.php b/lang/pt/validation.php index d98f9857e..df414c992 100644 --- a/lang/pt/validation.php +++ b/lang/pt/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'O formato da URL :attribute é inválido.', 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/pt_BR/activities.php b/lang/pt_BR/activities.php index d2b963244..845b41cc3 100644 --- a/lang/pt_BR/activities.php +++ b/lang/pt_BR/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhook excluído', 'webhook_delete_notification' => 'Webhook excluido com sucesso', + // 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' => 'usuário criado', 'user_create_notification' => 'Usuário criado com sucesso', diff --git a/lang/pt_BR/editor.php b/lang/pt_BR/editor.php index 42e20af87..e251ba966 100644 --- a/lang/pt_BR/editor.php +++ b/lang/pt_BR/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Sobre o editor', 'about_title' => 'Sobre o Editor WYSIWYG', 'editor_license' => 'Licença do Editor e Direitos Autorais', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Este editor é construído usando :tinyLink que é fornecido sob a licença MIT.', 'editor_tiny_license_link' => 'Os dados relativos aos direitos de autor e à licença do TinyMCE podem ser encontrados aqui.', 'save_continue' => 'Salvar Página e Continuar', diff --git a/lang/pt_BR/entities.php b/lang/pt_BR/entities.php index d23f677d9..b3e794a2d 100644 --- a/lang/pt_BR/entities.php +++ b/lang/pt_BR/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Arquivo PDF', 'export_text' => 'Arquivo de texto simples', 'export_md' => 'Arquivo de redução', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Modelo padrão de página', 'default_template_explain' => 'Atribuir o modelo de página que será usado como padrão para todas as páginas criadas neste livro. Tenha em mente que isto será usado apenas se o criador da página tiver acesso de visualização ao modelo de página escolhido.', 'default_template_select' => 'Selecione uma página de modelo', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissões', @@ -85,7 +106,7 @@ return [ // Shelves 'shelf' => 'Estante', 'shelves' => 'Estantes', - 'x_shelves' => ': count Estante|: count Estantes', + 'x_shelves' => ':count Estante|:count Estantes', 'shelves_empty' => 'Nenhuma estante foi criada', 'shelves_create' => 'Criar Prateleira', 'shelves_popular' => 'Estantes Populares', diff --git a/lang/pt_BR/errors.php b/lang/pt_BR/errors.php index 3fa7d44c7..15464c95a 100644 --- a/lang/pt_BR/errors.php +++ b/lang/pt_BR/errors.php @@ -106,6 +106,18 @@ return [ 'app_down' => 'Agora está baixo', 'back_soon' => 'Vai estar de volta em breve.', + // 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.', + '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.', + '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' => 'Nenhum código de autorização encontrado na requisição', 'api_bad_authorization_format' => 'Um código de autorização foi encontrado na requisição, mas o formato parece incorreto', diff --git a/lang/pt_BR/settings.php b/lang/pt_BR/settings.php index 881604d4d..a5b68c7e4 100644 --- a/lang/pt_BR/settings.php +++ b/lang/pt_BR/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Acessar API do sistema', 'role_manage_settings' => 'Gerenciar configurações da aplicação', 'role_export_content' => 'Exportar conteúdo', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Alterar página de edição', 'role_notifications' => 'Receber e gerenciar notificações', 'role_asset' => 'Permissões de Ativos', diff --git a/lang/pt_BR/validation.php b/lang/pt_BR/validation.php index 68b850298..b3a9a7294 100644 --- a/lang/pt_BR/validation.php +++ b/lang/pt_BR/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'O formato da URL :attribute é inválido.', 'uploaded' => 'O arquivo não pôde ser carregado. O servidor pode não aceitar arquivos deste tamanho.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ro/activities.php b/lang/ro/activities.php index d076b91e8..951ca2347 100644 --- a/lang/ro/activities.php +++ b/lang/ro/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'a șters webhook', 'webhook_delete_notification' => 'Webhook șters cu succes', + // 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' => 'utilizator creat', 'user_create_notification' => 'Utilizator creat cu succes', diff --git a/lang/ro/editor.php b/lang/ro/editor.php index 65208957b..a22e239aa 100644 --- a/lang/ro/editor.php +++ b/lang/ro/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Despre editor', 'about_title' => 'Despre editorul WYSIWYG', 'editor_license' => 'Editor licență și drepturi de autor', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Acest editor este construit folosind :tinyLink care este furnizat sub licența MIT.', 'editor_tiny_license_link' => 'Detaliile privind drepturile de autor şi licența TinyMCE pot fi consultate aici.', 'save_continue' => 'Salvează pagina și continuă', diff --git a/lang/ro/entities.php b/lang/ro/entities.php index 676562f46..f6bec4cb9 100644 --- a/lang/ro/entities.php +++ b/lang/ro/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Fișier PDF', 'export_text' => 'Fișier text simplu', 'export_md' => 'Fișier Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permisiuni', diff --git a/lang/ro/errors.php b/lang/ro/errors.php index 0989f797c..07c295b07 100644 --- a/lang/ro/errors.php +++ b/lang/ro/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName nu funcționează acum', 'back_soon' => 'Va reveni în curând.', + // 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.', + '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.', + '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' => 'Nu s-a găsit niciun token de autorizare la cerere', 'api_bad_authorization_format' => 'A fost găsit un token de autorizare, dar formatul este incorect', diff --git a/lang/ro/settings.php b/lang/ro/settings.php index 338f49200..ee402085a 100644 --- a/lang/ro/settings.php +++ b/lang/ro/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Accesează API sistem', 'role_manage_settings' => 'Gestionează setările aplicației', 'role_export_content' => 'Exportă conținut', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Schimbă editorul de pagină', 'role_notifications' => 'Primire și gestionare notificări', 'role_asset' => 'Permisiuni active', diff --git a/lang/ro/validation.php b/lang/ro/validation.php index 523943850..56a3e2e05 100644 --- a/lang/ro/validation.php +++ b/lang/ro/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute nu este valid.', 'uploaded' => 'Fişierul nu a putut fi încărcat. Serverul nu poate accepta fişiere de această dimensiune.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/ru/activities.php b/lang/ru/activities.php index df6ae9062..3fe46181d 100644 --- a/lang/ru/activities.php +++ b/lang/ru/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'удалил вебхук', 'webhook_delete_notification' => 'Вебхук успешно удален', + // 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' => 'создал пользователя', 'user_create_notification' => 'Пользователь успешно создан', diff --git a/lang/ru/editor.php b/lang/ru/editor.php index b93bd8840..7e12acfff 100644 --- a/lang/ru/editor.php +++ b/lang/ru/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'О редакторе', 'about_title' => 'О редакторе WYSIWYG', 'editor_license' => 'Лицензия редактора и авторские права', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Этот редактор собран с помощью :tinyLink, который предоставляется под MIT лицензией.', 'editor_tiny_license_link' => 'Авторские права и подробности лицензии TinyMCE вы можете найти здесь.', 'save_continue' => 'Сохранить страницу и продолжить', diff --git a/lang/ru/entities.php b/lang/ru/entities.php index 5de25a50d..795c287cb 100644 --- a/lang/ru/entities.php +++ b/lang/ru/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF файл', 'export_text' => 'Текстовый файл', 'export_md' => 'Файл Markdown', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Шаблон страницы по умолчанию', 'default_template_explain' => 'Назначить шаблон страницы, который будет использоваться в качестве содержимого по умолчанию для всех страниц, созданных в этом элементе. Имейте в виду, что это будет работать, только если создатель страницы имеет доступ к выбранной странице шаблона.', 'default_template_select' => 'Выберите страницу шаблона', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Разрешения', diff --git a/lang/ru/errors.php b/lang/ru/errors.php index 033a64840..ceae383c6 100644 --- a/lang/ru/errors.php +++ b/lang/ru/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName в данный момент не доступно', 'back_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.', + '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.', + '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' => 'Отсутствует токен авторизации в запросе', 'api_bad_authorization_format' => 'Токен авторизации найден, но формат запроса неверен', diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 4357cbddf..32b5367c6 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Доступ к системному API', 'role_manage_settings' => 'Управление настройками приложения', 'role_export_content' => 'Экспорт контента', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Изменение редактора страниц', 'role_notifications' => 'Получение и управление уведомлениями', 'role_asset' => 'Права доступа к материалам', diff --git a/lang/ru/validation.php b/lang/ru/validation.php index 88bd9a41f..156a05fe6 100644 --- a/lang/ru/validation.php +++ b/lang/ru/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Формат :attribute некорректен.', 'uploaded' => 'Не удалось загрузить файл. Сервер не может принимать файлы такого размера.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sk/activities.php b/lang/sk/activities.php index 6dad803e0..a1ef597ca 100644 --- a/lang/sk/activities.php +++ b/lang/sk/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'odstránil(a) si webhook', 'webhook_delete_notification' => 'Webhook úspešne odstránený', + // 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' => 'užívateľ vytvorený', 'user_create_notification' => 'User successfully created', diff --git a/lang/sk/editor.php b/lang/sk/editor.php index ec7f24d65..5b7ee2f49 100644 --- a/lang/sk/editor.php +++ b/lang/sk/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'O editore', 'about_title' => 'O WYSIWYG Editore', 'editor_license' => 'Licencia editora a autorské práva', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Tento editor je vytvorený pomocou :tinyLink, ktorý je poskytovaný pod licenciou MIT.', 'editor_tiny_license_link' => 'Podrobnosti o autorských právach a licenciách TinyMCE nájdete tu.', 'save_continue' => 'Uložiť a pokračovať', diff --git a/lang/sk/entities.php b/lang/sk/entities.php index b296f4f67..882ac533d 100644 --- a/lang/sk/entities.php +++ b/lang/sk/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF súbor', 'export_text' => 'Súbor s čistým textom', 'export_md' => 'Súbor Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Oprávnenia', diff --git a/lang/sk/errors.php b/lang/sk/errors.php index 036f2b2d4..143259f3a 100644 --- a/lang/sk/errors.php +++ b/lang/sk/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName je momentálne nedostupná', 'back_soon' => 'Čoskoro bude opäť dostupná.', + // 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.', + '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.', + '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' => 'V žiadosti sa nenašiel žiadny autorizačný token', 'api_bad_authorization_format' => 'V žiadosti sa našiel autorizačný token, ale formát sa zdal nesprávny', diff --git a/lang/sk/settings.php b/lang/sk/settings.php index 40b256229..cb8fb9fa9 100644 --- a/lang/sk/settings.php +++ b/lang/sk/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'API prístupového systému', 'role_manage_settings' => 'Spravovať nastavenia aplikácie', 'role_export_content' => 'Exportovať obsah', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Zmeniť editor stránky', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Oprávnenia majetku', diff --git a/lang/sk/validation.php b/lang/sk/validation.php index dfc821b7c..415f7cf05 100644 --- a/lang/sk/validation.php +++ b/lang/sk/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute formát je neplatný.', 'uploaded' => 'Súbor sa nepodarilo nahrať. Server nemusí akceptovať súbory tejto veľkosti.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sl/activities.php b/lang/sl/activities.php index 027c17f87..11627a89a 100644 --- a/lang/sl/activities.php +++ b/lang/sl/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'izbrisan webhook', 'webhook_delete_notification' => 'Webhook uspešno izbrisan', + // 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' => 'ustvarjen uporabnik', 'user_create_notification' => 'Uporabnik uspešno ustvarjen', diff --git a/lang/sl/editor.php b/lang/sl/editor.php index b6f462eb5..09b311018 100644 --- a/lang/sl/editor.php +++ b/lang/sl/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'O urejevalniku', 'about_title' => 'O Urejevalniku WYSIWYG', 'editor_license' => 'Licenca in avtorske pravice Urejevalnika', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Urejevalnik je ustvarjen z uporabo :tinyLink pod pogoji licence MIT.', 'editor_tiny_license_link' => 'Podrobnosti o avtorskih pravicah in licenci za TinyMCE lahko preberete tukaj.', 'save_continue' => 'Shrani stran in Nadaljuj', diff --git a/lang/sl/entities.php b/lang/sl/entities.php index be58595cf..e118bfaac 100644 --- a/lang/sl/entities.php +++ b/lang/sl/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF datoteka (.pdf)', 'export_text' => 'Navadna besedilna datoteka', '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Dovoljenja', diff --git a/lang/sl/errors.php b/lang/sl/errors.php index 7269b7555..9a8759684 100644 --- a/lang/sl/errors.php +++ b/lang/sl/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName trenutno ni dosegljiva', 'back_soon' => 'Kmalu bo ponovno dosegljiva.', + // 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.', + '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.', + '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' => 'Avtorizacija ni bila najdena', 'api_bad_authorization_format' => 'Avtorizacija je bila najdena, vendar je v napačni obliki', diff --git a/lang/sl/settings.php b/lang/sl/settings.php index 7de5f24e7..77af9dac4 100644 --- a/lang/sl/settings.php +++ b/lang/sl/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'API za dostop do sistema', 'role_manage_settings' => 'Nastavitve za upravljanje', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Sistemska dovoljenja', diff --git a/lang/sl/validation.php b/lang/sl/validation.php index 012bfe4a7..0d9b56c10 100644 --- a/lang/sl/validation.php +++ b/lang/sl/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute oblika ni veljavna.', 'uploaded' => 'Datoteke ni bilo mogoče naložiti. Strežnik morda ne sprejema datotek te velikosti.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sq/activities.php b/lang/sq/activities.php index b3590c9bf..b7ca7d02d 100644 --- a/lang/sq/activities.php +++ b/lang/sq/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'fshiu uebhook', 'webhook_delete_notification' => 'Uebhook-u u fshi me sukses', + // 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' => 'krijoi përdorues', 'user_create_notification' => 'Përdoruesi u krijua me sukses', diff --git a/lang/sq/editor.php b/lang/sq/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/sq/editor.php +++ b/lang/sq/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/sq/entities.php b/lang/sq/entities.php index 35e6f050b..26a563a7e 100644 --- a/lang/sq/entities.php +++ b/lang/sq/entities.php @@ -39,9 +39,30 @@ return [ '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/sq/errors.php b/lang/sq/errors.php index 9c40aa9ed..9d7383796 100644 --- a/lang/sq/errors.php +++ b/lang/sq/errors.php @@ -105,6 +105,18 @@ return [ '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.', + '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.', + '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', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/sq/settings.php b/lang/sq/settings.php index 5427cb941..c0b6b692a 100644 --- a/lang/sq/settings.php +++ b/lang/sq/settings.php @@ -162,6 +162,7 @@ return [ '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/lang/sq/validation.php b/lang/sq/validation.php index 2a676c7c4..d9b982d1e 100644 --- a/lang/sq/validation.php +++ b/lang/sq/validation.php @@ -105,6 +105,11 @@ return [ '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_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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sr/activities.php b/lang/sr/activities.php index 4077c0745..9a72cfc5e 100644 --- a/lang/sr/activities.php +++ b/lang/sr/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'обрисан вебхоок', 'webhook_delete_notification' => 'Вебхоок је успешно обрисан', + // 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' => 'креирао корисника', 'user_create_notification' => 'Корисник је успешно креиран', diff --git a/lang/sr/editor.php b/lang/sr/editor.php index b376e542c..8b7690e2c 100644 --- a/lang/sr/editor.php +++ b/lang/sr/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'О уређивачу', 'about_title' => 'О уређивачу WYSIWYG', 'editor_license' => 'Уредничка лиценца и ауторска права', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Овај уређивач је направљен помоћу :tinyLink који је обезбеђен под МИТ лиценцом.', 'editor_tiny_license_link' => 'Детаље о ауторским правима и лиценци за ТиниМЦЕ можете пронаћи овде.', 'save_continue' => 'Сачувај страницу и настави', diff --git a/lang/sr/entities.php b/lang/sr/entities.php index a6964a103..0c27ddf59 100644 --- a/lang/sr/entities.php +++ b/lang/sr/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF датотека', 'export_text' => 'Датотеке чистог текста', 'export_md' => 'Markdown File', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Подразумевани шаблон странице', 'default_template_explain' => 'Доделите шаблон странице који ће се користити као подразумевани садржај за све странице креиране у оквиру ове ставке. Имајте на уму да ће се ово користити само ако креатор странице има приступ за преглед изабране странице шаблона.', 'default_template_select' => 'Изаберите страницу са шаблоном', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Дозволе', diff --git a/lang/sr/errors.php b/lang/sr/errors.php index acf0d5f36..ee8443461 100644 --- a/lang/sr/errors.php +++ b/lang/sr/errors.php @@ -105,6 +105,18 @@ return [ '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.', + '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.', + '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', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/sr/settings.php b/lang/sr/settings.php index e24601913..6bd5f5252 100644 --- a/lang/sr/settings.php +++ b/lang/sr/settings.php @@ -162,6 +162,7 @@ return [ '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/lang/sr/validation.php b/lang/sr/validation.php index 2a676c7c4..d9b982d1e 100644 --- a/lang/sr/validation.php +++ b/lang/sr/validation.php @@ -105,6 +105,11 @@ return [ '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_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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/sv/activities.php b/lang/sv/activities.php index abe53c856..5244b2243 100644 --- a/lang/sv/activities.php +++ b/lang/sv/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'raderade webhook', 'webhook_delete_notification' => 'Webhook har tagits bort', + // 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' => 'skapade användare', 'user_create_notification' => 'Användare skapades', diff --git a/lang/sv/editor.php b/lang/sv/editor.php index 25e443511..4badcc0e3 100644 --- a/lang/sv/editor.php +++ b/lang/sv/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Om redigeraren', 'about_title' => 'Om WYSIWYG-redigeraren', 'editor_license' => 'Licens och upphovsrätt för redigerare', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Denna redigerare är byggd med :tinyLink som tillhandahålls under MIT licensen.', 'editor_tiny_license_link' => 'Upphovsrätten och licensuppgifterna för TinyMCE hittar du här.', 'save_continue' => 'Spara sida & fortsätt', diff --git a/lang/sv/entities.php b/lang/sv/entities.php index d01de6380..5df1f43dc 100644 --- a/lang/sv/entities.php +++ b/lang/sv/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF-fil', 'export_text' => 'Textfil', 'export_md' => 'Markdown-fil', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Förvald sidmall', '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', + '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', + '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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Rättigheter', diff --git a/lang/sv/errors.php b/lang/sv/errors.php index d6049b20c..86ea215d0 100644 --- a/lang/sv/errors.php +++ b/lang/sv/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName är nere just nu', 'back_soon' => 'Vi är snart tillbaka.', + // 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.', + '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.', + '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' => 'Ingen auktoriseringstoken hittades på denna begäran', 'api_bad_authorization_format' => 'En auktoriseringstoken hittades på denna begäran men formatet verkade felaktigt', diff --git a/lang/sv/settings.php b/lang/sv/settings.php index c9f00a340..bd6afcf28 100644 --- a/lang/sv/settings.php +++ b/lang/sv/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Åtkomst till systemets API', 'role_manage_settings' => 'Hantera appinställningar', 'role_export_content' => 'Exportera innehåll', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Ändra sidredigerare', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Tillgång till innehåll', diff --git a/lang/sv/validation.php b/lang/sv/validation.php index b9c609f48..4cc98c575 100644 --- a/lang/sv/validation.php +++ b/lang/sv/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Formatet på :attribute är ogiltigt.', 'uploaded' => 'Filen kunde inte laddas upp. Servern kanske inte tillåter filer med denna storlek.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/tk/activities.php b/lang/tk/activities.php index 092398ef0..7c3454d41 100644 --- a/lang/tk/activities.php +++ b/lang/tk/activities.php @@ -84,6 +84,14 @@ return [ '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/tk/editor.php b/lang/tk/editor.php index de9aa0ece..a61b46042 100644 --- a/lang/tk/editor.php +++ b/lang/tk/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'About the editor', 'about_title' => 'About the WYSIWYG Editor', 'editor_license' => 'Editor License & Copyright', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'This editor is built using :tinyLink which is provided under the MIT license.', 'editor_tiny_license_link' => 'The copyright and license details of TinyMCE can be found here.', 'save_continue' => 'Save Page & Continue', diff --git a/lang/tk/entities.php b/lang/tk/entities.php index 35e6f050b..26a563a7e 100644 --- a/lang/tk/entities.php +++ b/lang/tk/entities.php @@ -39,9 +39,30 @@ return [ '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Permissions', diff --git a/lang/tk/errors.php b/lang/tk/errors.php index 9c40aa9ed..9d7383796 100644 --- a/lang/tk/errors.php +++ b/lang/tk/errors.php @@ -105,6 +105,18 @@ return [ '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.', + '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.', + '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', 'api_bad_authorization_format' => 'An authorization token was found on the request but the format appeared incorrect', diff --git a/lang/tk/settings.php b/lang/tk/settings.php index 5427cb941..c0b6b692a 100644 --- a/lang/tk/settings.php +++ b/lang/tk/settings.php @@ -162,6 +162,7 @@ return [ '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/lang/tk/validation.php b/lang/tk/validation.php index 2a676c7c4..d9b982d1e 100644 --- a/lang/tk/validation.php +++ b/lang/tk/validation.php @@ -105,6 +105,11 @@ return [ '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_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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/tr/activities.php b/lang/tr/activities.php index bfafba4a4..fcecd6de3 100644 --- a/lang/tr/activities.php +++ b/lang/tr/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'web kancası silindi', 'webhook_delete_notification' => 'Web kancası başarıyla silindi', + // 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' => 'Kullanıcı başarıyla oluşturuldu', diff --git a/lang/tr/editor.php b/lang/tr/editor.php index 585d6ec05..3dc990f0c 100644 --- a/lang/tr/editor.php +++ b/lang/tr/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Editör hakkında', 'about_title' => 'WYSIWYG editor hakkında', 'editor_license' => 'Editor Lisans ve Telif Hakkı', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Bu düzenleyici, MIT lisansı altında sağlanan :tinyLink kullanılarak oluşturulmuştur.', 'editor_tiny_license_link' => 'TinyMCE telif ve lisans bilgilerini burada bulabilirsiniz.', 'save_continue' => 'Kaydet & Devam Et', diff --git a/lang/tr/entities.php b/lang/tr/entities.php index ae60e8d07..a73cdd853 100644 --- a/lang/tr/entities.php +++ b/lang/tr/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF Dosyası', 'export_text' => 'Düz Metin Dosyası', 'export_md' => 'Markdown Dosyası', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'İzinler', diff --git a/lang/tr/errors.php b/lang/tr/errors.php index 7087aca47..fd7426871 100644 --- a/lang/tr/errors.php +++ b/lang/tr/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName şu anda erişilemez durumda', 'back_soon' => 'En kısa sürede tekrar erişilebilir duruma gelecektir.', + // 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.', + '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.', + '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' => 'Yapılan istekte, yetkilendirme anahtarı bulunamadı', 'api_bad_authorization_format' => 'Yapılan istekte bir yetkilendirme anahtarı bulundu fakat doğru görünmüyor', diff --git a/lang/tr/settings.php b/lang/tr/settings.php index 8b38e7b95..68c470b0a 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Sistem programlama arayüzüne (API) eriş', 'role_manage_settings' => 'Uygulama ayarlarını yönet', 'role_export_content' => 'İçeriği dışa aktar', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Yazı editörünü değiştir', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Varlık Yetkileri', diff --git a/lang/tr/validation.php b/lang/tr/validation.php index 640fe6131..9dbfadd6b 100644 --- a/lang/tr/validation.php +++ b/lang/tr/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute formatı geçersiz.', 'uploaded' => 'Dosya yüklemesi başarısız oldu. Sunucu, bu boyuttaki dosyaları kabul etmiyor olabilir.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/uk/activities.php b/lang/uk/activities.php index 1ab0eca61..86195224c 100644 --- a/lang/uk/activities.php +++ b/lang/uk/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'видалений вебхук', 'webhook_delete_notification' => 'Вебхуки успішно видалено', + // 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' => 'створений користувач', 'user_create_notification' => 'Користувач успішно створений', diff --git a/lang/uk/editor.php b/lang/uk/editor.php index 176f7fab7..85efbc661 100644 --- a/lang/uk/editor.php +++ b/lang/uk/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Про редактор', 'about_title' => 'Про WYSIWYG редактор', 'editor_license' => 'Ліцензія редактора і авторські права', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Цей редактор побудований за допомогою :tinylink, яке надається за ліцензією MIT.', 'editor_tiny_license_link' => 'Тут можна знайти авторські та умови ліцензії.', 'save_continue' => 'Зберегти і продовжити', diff --git a/lang/uk/entities.php b/lang/uk/entities.php index 01549e7f3..e955f5801 100644 --- a/lang/uk/entities.php +++ b/lang/uk/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF файл', 'export_text' => 'Текстовий файл', 'export_md' => 'Файл розмітки', + 'export_zip' => 'Portable ZIP', 'default_template' => 'Типовий шаблон сторінки', 'default_template_explain' => 'Призначити шаблон сторінки, який буде використовуватися як типовий вміст для всіх сторінок, створених у цьому елементі. Майте на увазі, що ця сторінка буде використана лише у випадку, якщо вона має доступ до обраної сторінки шаблону.', 'default_template_select' => 'Виберіть сторінку шаблону', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Дозволи', diff --git a/lang/uk/errors.php b/lang/uk/errors.php index 7698093c1..5c8148fec 100644 --- a/lang/uk/errors.php +++ b/lang/uk/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName зараз недоступний', 'back_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.', + '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.', + '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' => 'У запиті не знайдено токен авторизації', 'api_bad_authorization_format' => 'У запиті знайдено токен авторизації, але формат недійсний', diff --git a/lang/uk/settings.php b/lang/uk/settings.php index e26a2a8b8..a897439a1 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Доступ до системного API', 'role_manage_settings' => 'Керування налаштуваннями програми', 'role_export_content' => 'Вміст експорту', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Змінити редактор сторінок', 'role_notifications' => 'Отримувати та керувати повідомленнями', 'role_asset' => 'Дозволи', diff --git a/lang/uk/validation.php b/lang/uk/validation.php index 51a591666..ae31bb237 100644 --- a/lang/uk/validation.php +++ b/lang/uk/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Формат поля :attribute неправильний.', 'uploaded' => 'Не вдалося завантажити файл. Сервер може не приймати файли такого розміру.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/uz/activities.php b/lang/uz/activities.php index 0cbfd264a..19d52adc4 100644 --- a/lang/uz/activities.php +++ b/lang/uz/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'o‘chirilgan webhook', 'webhook_delete_notification' => 'Webhook muvaffaqiyatli o‘chirildi', + // 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/uz/editor.php b/lang/uz/editor.php index f3aa4f5cd..deea210ba 100644 --- a/lang/uz/editor.php +++ b/lang/uz/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Muharrir haqida', 'about_title' => 'WYSIWYG muharriri haqida', 'editor_license' => 'Muharrir litsenziyasi va mualliflik huquqi', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Ushbu muharrir MIT litsenziyasi ostida taqdim etilgan :tinyLink yordamida yaratilgan.', 'editor_tiny_license_link' => 'TinyMCE mualliflik huquqi va litsenziya tafsilotlarini bu yerda topishingiz mumkin.', 'save_continue' => 'Sahifani saqlang va Davom eting', diff --git a/lang/uz/entities.php b/lang/uz/entities.php index f08182071..fa198c68d 100644 --- a/lang/uz/entities.php +++ b/lang/uz/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF holatida', 'export_text' => 'Oddiy matn holatida', 'export_md' => 'Markdown fayli holatida', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Huquqlar', diff --git a/lang/uz/errors.php b/lang/uz/errors.php index 63a61ef84..a0d86c441 100644 --- a/lang/uz/errors.php +++ b/lang/uz/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName hozir ishlamayapti', 'back_soon' => 'Tez orada zaxiralanadi.', + // 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.', + '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.', + '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' => 'So‘rovda hech qanday avtorizatsiya belgisi topilmadi', 'api_bad_authorization_format' => 'So‘rovda avtorizatsiya belgisi topildi, lekin format noto‘g‘ri ko‘rindi', diff --git a/lang/uz/settings.php b/lang/uz/settings.php index 4ce7c0609..fabef5383 100644 --- a/lang/uz/settings.php +++ b/lang/uz/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Kirish tizimi API', 'role_manage_settings' => 'Ilova sozlamalarini boshqaring', 'role_export_content' => 'Kontentni eksport qilish', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Sahifa muharririni o\'zgartirish', 'role_notifications' => 'Bildirishnomalarni qabul qilish va boshqarish', 'role_asset' => 'Obyektga ruxsatlar', diff --git a/lang/uz/validation.php b/lang/uz/validation.php index 4606a0128..d9d0fa0b2 100644 --- a/lang/uz/validation.php +++ b/lang/uz/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute URL formatida emas.', 'uploaded' => 'Faylni yuklashda xatolik. Server bunday hajmdagi faylllarni yuklamasligi mumkin.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/vi/activities.php b/lang/vi/activities.php index d11de40e4..2262e37a0 100644 --- a/lang/vi/activities.php +++ b/lang/vi/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'đã xóa webhook', 'webhook_delete_notification' => 'Webhook đã được xóa thành công', + // 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' => 'đã tạo người dùng', 'user_create_notification' => 'Người dùng được tạo thành công', diff --git a/lang/vi/editor.php b/lang/vi/editor.php index 76203c2d2..2e36df8dd 100644 --- a/lang/vi/editor.php +++ b/lang/vi/editor.php @@ -163,6 +163,8 @@ return [ 'about' => 'Giới thiệu về trình soạn thảo', 'about_title' => 'Giới thiệu về trình soạn thảo WYSIWYG', 'editor_license' => 'Giáy phép & Bản quyền của trình soạn thảo', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => 'Trình soạn thảo này được xây dựng bằng các sử dụng :tinyLink theo giấy phép MIT.', 'editor_tiny_license_link' => 'Chi tiết về bản quyền và giấy phép của TinyMCE có thể được tìm thấy tại đây.', 'save_continue' => 'Lưu trang & Tiếp tục', diff --git a/lang/vi/entities.php b/lang/vi/entities.php index f8f6ca293..11c47a8d5 100644 --- a/lang/vi/entities.php +++ b/lang/vi/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'Tệp PDF', 'export_text' => 'Tệp văn bản thuần túy', 'export_md' => 'Tệp Markdown', + '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', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => 'Quyền', diff --git a/lang/vi/errors.php b/lang/vi/errors.php index a2a7d491b..66ad23b72 100644 --- a/lang/vi/errors.php +++ b/lang/vi/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName hiện đang ngoại tuyến', 'back_soon' => 'Nó sẽ sớm hoạt động trở lại.', + // 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.', + '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.', + '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' => 'Không tìm thấy token ủy quyền trong yêu cầu', 'api_bad_authorization_format' => 'Đã tìm thấy một token ủy quyền trong yêu cầu nhưng định dạng hiển thị không hợp lệ', diff --git a/lang/vi/settings.php b/lang/vi/settings.php index 63024cbd3..410d28d59 100644 --- a/lang/vi/settings.php +++ b/lang/vi/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => 'Truy cập đến API hệ thống', 'role_manage_settings' => 'Quản lý cài đặt của ứng dụng', 'role_export_content' => 'Export content', + 'role_import_content' => 'Import content', 'role_editor_change' => 'Change page editor', 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Quyền tài sản (asset)', diff --git a/lang/vi/validation.php b/lang/vi/validation.php index e7bcfd3e2..9683c0857 100644 --- a/lang/vi/validation.php +++ b/lang/vi/validation.php @@ -105,6 +105,11 @@ return [ 'url' => 'Định dạng của :attribute không hợp lệ.', 'uploaded' => 'Tệp tin đã không được tải lên. Máy chủ không chấp nhận các tệp tin với dung lượng lớn như tệp tin trên.', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/zh_CN/activities.php b/lang/zh_CN/activities.php index 54c93b0ef..7edf9bc79 100644 --- a/lang/zh_CN/activities.php +++ b/lang/zh_CN/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'Webhook 已删除', 'webhook_delete_notification' => 'Webhook 删除成功', + // 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' => '用户已创建', 'user_create_notification' => '用户创建成功', diff --git a/lang/zh_CN/components.php b/lang/zh_CN/components.php index f04a254ec..c2ca50d36 100644 --- a/lang/zh_CN/components.php +++ b/lang/zh_CN/components.php @@ -17,7 +17,7 @@ return [ 'image_page_title' => '查看上传到本页面的图片', 'image_search_hint' => '按图片名称搜索', 'image_uploaded' => '上传于 :uploadedDate', - 'image_uploaded_by' => '由 :username 上传', + 'image_uploaded_by' => '由 :userName 上传', 'image_uploaded_to' => '上传到 :pageLink', 'image_updated' => ':updateDate 更新', 'image_load_more' => '显示更多', diff --git a/lang/zh_CN/editor.php b/lang/zh_CN/editor.php index 1a07362a5..5f4ffdcb9 100644 --- a/lang/zh_CN/editor.php +++ b/lang/zh_CN/editor.php @@ -163,6 +163,8 @@ return [ 'about' => '关于编辑器', 'about_title' => '关于所见即所得(WYSIWYG)编辑器', 'editor_license' => '编辑器许可证与版权信息', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => '此编辑器是用 :tinyLink 构建的,基于 MIT 许可证。', 'editor_tiny_license_link' => 'TinyMCE 的版权和许可证详细信息可以在这里找到。', 'save_continue' => '保存页面并继续', diff --git a/lang/zh_CN/entities.php b/lang/zh_CN/entities.php index 5083b263e..9f6ee7ecf 100644 --- a/lang/zh_CN/entities.php +++ b/lang/zh_CN/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF文件', 'export_text' => '纯文本文件', 'export_md' => 'Markdown 文件', + 'export_zip' => 'Portable ZIP', 'default_template' => '默认页面模板', 'default_template_explain' => '指定一个页面模板,该模板将作为此项目中所有页面的默认内容。请注意,仅当页面创建者具有对所选页面模板的查看访问权限时,此功能才会生效。', 'default_template_select' => '选择模板页面', + '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 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_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', + '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.', + 'import_errors' => 'Import Errors', + 'import_errors_desc' => 'The follow errors occurred during the import attempt:', // Permissions and restrictions 'permissions' => '权限', diff --git a/lang/zh_CN/errors.php b/lang/zh_CN/errors.php index b51be47c1..3c5f262a1 100644 --- a/lang/zh_CN/errors.php +++ b/lang/zh_CN/errors.php @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName现在正在关闭', 'back_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.', + '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.', + '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' => '未在请求中找到授权令牌', 'api_bad_authorization_format' => '已在请求中找到授权令牌,但格式貌似不正确', diff --git a/lang/zh_CN/settings.php b/lang/zh_CN/settings.php index 4eae4a98d..3343877ab 100644 --- a/lang/zh_CN/settings.php +++ b/lang/zh_CN/settings.php @@ -162,6 +162,7 @@ return [ 'role_access_api' => '访问系统 API', 'role_manage_settings' => '管理 App 设置', 'role_export_content' => '导出内容', + 'role_import_content' => 'Import content', 'role_editor_change' => '更改页面编辑器', 'role_notifications' => '管理和接收通知', 'role_asset' => '资源许可', diff --git a/lang/zh_CN/validation.php b/lang/zh_CN/validation.php index 9f24f3339..120dfd998 100644 --- a/lang/zh_CN/validation.php +++ b/lang/zh_CN/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute 格式无效。', 'uploaded' => '无法上传文件。 服务器可能不接受此大小的文件。', + '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.', + // Custom validation lines 'custom' => [ 'password-confirm' => [ diff --git a/lang/zh_TW/activities.php b/lang/zh_TW/activities.php index dddca399a..be12e22c2 100644 --- a/lang/zh_TW/activities.php +++ b/lang/zh_TW/activities.php @@ -84,6 +84,14 @@ return [ 'webhook_delete' => 'webhook 已刪除', 'webhook_delete_notification' => 'Webhook 已刪除成功', + // Imports + 'import_create' => '已建立匯入', + 'import_create_notification' => '成功上傳匯入', + 'import_run' => '已更新匯入', + 'import_run_notification' => '成功匯入內容', + 'import_delete' => '已刪除匯入', + 'import_delete_notification' => '匯入刪除成功', + // Users 'user_create' => '建立使用者', 'user_create_notification' => '使用者已成功建立。', diff --git a/lang/zh_TW/common.php b/lang/zh_TW/common.php index 8b003a730..f8023bdd5 100644 --- a/lang/zh_TW/common.php +++ b/lang/zh_TW/common.php @@ -109,5 +109,5 @@ return [ 'terms_of_service' => '服務條款', // OpenSearch - 'opensearch_description' => 'Search :appName', + 'opensearch_description' => '搜尋 :appName', ]; diff --git a/lang/zh_TW/editor.php b/lang/zh_TW/editor.php index 2ee37f863..7eeb48501 100644 --- a/lang/zh_TW/editor.php +++ b/lang/zh_TW/editor.php @@ -163,6 +163,8 @@ return [ 'about' => '關於編輯器', 'about_title' => '關於所見即所得(WYSIWYG)編輯器', 'editor_license' => '編輯器許可證與版權信息', + 'editor_lexical_license' => 'This editor is built as a fork of :lexicalLink which is distributed under the MIT license.', + 'editor_lexical_license_link' => 'Full license details can be found here.', 'editor_tiny_license' => '此編輯器是用 :tinyLink 構建的,基於 MIT 許可證。', 'editor_tiny_license_link' => 'TinyMCE 的版權和許可證詳細信息可以在這裡找到。', 'save_continue' => '保存頁面並繼續', diff --git a/lang/zh_TW/entities.php b/lang/zh_TW/entities.php index 2f96bea2e..ee8113bee 100644 --- a/lang/zh_TW/entities.php +++ b/lang/zh_TW/entities.php @@ -39,9 +39,30 @@ return [ 'export_pdf' => 'PDF 檔案', 'export_text' => '純文字檔案', 'export_md' => 'Markdown 檔案', + 'export_zip' => '可攜式 ZIP', 'default_template' => '預設頁面範本', 'default_template_explain' => '請設定一個頁面範本,作為新頁面的預設內容。請注意,這僅限於作者擁有頁面範本讀取權限時才能夠使用。', 'default_template_select' => '選擇一個頁面範本', + 'import' => '匯入', + 'import_validate' => '驗證匯入', + 'import_desc' => '使用從相同或不同站點匯出的可攜式壓縮檔匯入書本、章節與頁面。選取 ZIP 檔案繼續。檔案上傳並通過驗證後,您就可以在下一個檢視中設定並確認匯入。', + 'import_zip_select' => '選取要上傳的 ZIP 檔案', + 'import_zip_validation_errors' => '驗證提供的 ZIP 檔案時偵測到錯誤:', + 'import_pending' => '擱置中的匯入', + 'import_pending_none' => '尚未開始匯入。', + 'import_continue' => '繼續匯入', + 'import_continue_desc' => '檢視要從上傳的 ZIP 檔匯入的內容。準備就緒後,執行匯入以將其內容加入本系統。成功匯入後,上傳的 ZIP 匯入檔案會自動移除。', + 'import_details' => '匯入詳細資訊', + 'import_run' => '執行匯入', + 'import_size' => ':size 匯入 ZIP 大小', + 'import_uploaded_at' => ':relativeTime 已上傳', + 'import_uploaded_by' => '上傳者', + 'import_location' => '匯入位置', + 'import_location_desc' => '為您匯入的內容選取目標位置。您需要相關權限才能在您選擇的位置內建立。', + 'import_delete_confirm' => '您確定要刪除此匯入嗎?', + 'import_delete_desc' => '這將會刪除已上傳的匯入 ZIP 檔案,且無法還原。', + 'import_errors' => '匯入錯誤', + 'import_errors_desc' => '嘗試匯入時發生以下錯誤:', // Permissions and restrictions 'permissions' => '權限', @@ -224,8 +245,8 @@ return [ 'pages_edit_switch_to_markdown_clean' => '(清除內容)', 'pages_edit_switch_to_markdown_stable' => '(保留內容)', 'pages_edit_switch_to_wysiwyg' => '切換到所見即所得編輯器', - 'pages_edit_switch_to_new_wysiwyg' => 'Switch to new WYSIWYG', - 'pages_edit_switch_to_new_wysiwyg_desc' => '(In Alpha Testing)', + 'pages_edit_switch_to_new_wysiwyg' => '切換為新的所見即所得編輯器', + 'pages_edit_switch_to_new_wysiwyg_desc' => '(仍在開發測試階段)', 'pages_edit_set_changelog' => '設定變更日誌', 'pages_edit_enter_changelog_desc' => '輸入對您所做變動的簡易描述', 'pages_edit_enter_changelog' => '輸入變更日誌', diff --git a/lang/zh_TW/errors.php b/lang/zh_TW/errors.php index 172c8ad0f..7a42ce511 100644 --- a/lang/zh_TW/errors.php +++ b/lang/zh_TW/errors.php @@ -78,7 +78,7 @@ return [ // Users 'users_cannot_delete_only_admin' => '您不能刪除唯一的管理員帳號', 'users_cannot_delete_guest' => '您不能刪除訪客使用者', - 'users_could_not_send_invite' => 'Could not create user since invite email failed to send', + 'users_could_not_send_invite' => '由於寄送邀請電子郵件失敗,因此無法建立使用者', // Roles 'role_cannot_be_edited' => '無法編輯這個角色', @@ -105,6 +105,18 @@ return [ 'app_down' => ':appName 離線中', 'back_soon' => '它應該很快就會重新上線。', + // Import + 'import_zip_cant_read' => '無法讀取 ZIP 檔案。', + 'import_zip_cant_decode_data' => '無法尋找並解碼 ZIP data.json 內容。', + 'import_zip_no_data' => 'ZIP 檔案資料沒有預期的書本、章節或頁面內容。', + 'import_validation_failed' => '匯入 ZIP 驗證失敗,發生錯誤:', + 'import_zip_failed_notification' => '匯入 ZIP 檔案失敗。', + 'import_perms_books' => '您缺乏建立書本所需的權限。', + 'import_perms_chapters' => '您缺乏建立章節所需的權限。', + 'import_perms_pages' => '您缺乏建立頁面所需的權限。', + 'import_perms_images' => '您缺乏建立影像所需的權限。', + 'import_perms_attachments' => '您缺乏建立附件所需的權限。', + // API errors 'api_no_authorization_found' => '在請求上找不到授權權杖', 'api_bad_authorization_format' => '在請求中找到授權權杖,但格式似乎不正確', diff --git a/lang/zh_TW/settings.php b/lang/zh_TW/settings.php index 9c893f1fa..b50d7649e 100644 --- a/lang/zh_TW/settings.php +++ b/lang/zh_TW/settings.php @@ -163,6 +163,7 @@ return [ 'role_access_api' => '存取系統 API', 'role_manage_settings' => '管理應用程式設定', 'role_export_content' => '匯出內容', + 'role_import_content' => '匯入內容', 'role_editor_change' => '重設頁面編輯器', 'role_notifications' => '管理和接收通知', 'role_asset' => '資源權限', diff --git a/lang/zh_TW/validation.php b/lang/zh_TW/validation.php index 6651f1396..300314707 100644 --- a/lang/zh_TW/validation.php +++ b/lang/zh_TW/validation.php @@ -105,6 +105,11 @@ return [ 'url' => ':attribute 格式無效。', 'uploaded' => '無法上傳文檔案, 伺服器可能不接受此大小的檔案。', + 'zip_file' => ':attribute 需要參照 ZIP 中的檔案。', + 'zip_file_mime' => ':attribute 需要參照類型為 :validTypes 的檔案,找到 :foundType。', + 'zip_model_expected' => '預期為資料物件,但找到「:type」。', + 'zip_unique' => '對於 ZIP 中的物件類型,:attribute 必須是唯一的。', + // Custom validation lines 'custom' => [ 'password-confirm' => [ From d56eea9279d37444265563d2794bba475719deb9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Dec 2024 11:27:58 +0000 Subject: [PATCH 88/89] Locales: Updated locale list with new languages --- app/Translation/LocaleManager.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Translation/LocaleManager.php b/app/Translation/LocaleManager.php index ca71df649..0e2e7372f 100644 --- a/app/Translation/LocaleManager.php +++ b/app/Translation/LocaleManager.php @@ -21,6 +21,7 @@ class LocaleManager protected array $localeMap = [ 'ar' => 'ar', 'bg' => 'bg_BG', + 'bn' => 'bn_BD', 'bs' => 'bs_BA', 'ca' => 'ca', 'cs' => 'cs_CZ', @@ -41,6 +42,7 @@ class LocaleManager 'hr' => 'hr_HR', 'hu' => 'hu_HU', 'id' => 'id_ID', + 'is' => 'is_IS', 'it' => 'it_IT', 'ja' => 'ja', 'ka' => 'ka_GE', From 980a684b1468fa15caa509aba534732b68a77fbb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 23 Dec 2024 11:53:35 +0000 Subject: [PATCH 89/89] Updated translator & dependency attribution before release v24.12 --- .github/translators.txt | 6 ++++ dev/licensing/js-library-licenses.txt | 39 ++++++++++++++++++++++++++ dev/licensing/php-library-licenses.txt | 10 +++---- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/.github/translators.txt b/.github/translators.txt index 9699be70f..9844f65b3 100644 --- a/.github/translators.txt +++ b/.github/translators.txt @@ -455,3 +455,9 @@ Rivo Zängov (Eraser) :: Estonian Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional madnjpn (madnjpn.) :: Georgian +Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic +Mohammad Aftab Uddin (chirohorit) :: Bengali +Yannis Karlaftis (meliseus) :: Greek +felixxx :: German Informal +randi (randi65535) :: Korean +test65428 :: Greek diff --git a/dev/licensing/js-library-licenses.txt b/dev/licensing/js-library-licenses.txt index a6a01cdcc..479941db1 100644 --- a/dev/licensing/js-library-licenses.txt +++ b/dev/licensing/js-library-licenses.txt @@ -507,6 +507,12 @@ Copyright: Copyright (c) 2011 Debuggable Limited <*****@**********.***> Source: git://github.com/felixge/node-delayed-stream.git Link: https://github.com/felixge/node-delayed-stream ----------- +detect-libc +License: Apache-2.0 +License File: node_modules/detect-libc/LICENSE +Source: git://github.com/lovell/detect-libc +Link: git://github.com/lovell/detect-libc +----------- detect-newline License: MIT License File: node_modules/detect-newline/license @@ -1819,6 +1825,13 @@ Copyright: Copyright (c) 2018 Tobias Reich Source: https://github.com/electerious/nice-try.git Link: https://github.com/electerious/nice-try ----------- +node-addon-api +License: MIT +License File: node_modules/node-addon-api/LICENSE.md +Copyright: Copyright (c) 2017 [Node.js API collaborators](https://github.com/nodejs/node-addon-api#collaborators) +Source: git://github.com/nodejs/node-addon-api.git +Link: https://github.com/nodejs/node-addon-api +----------- node-int64 License: MIT License File: node_modules/node-int64/LICENSE @@ -3525,6 +3538,11 @@ Copyright: Copyright (C) 2018 by Marijn Haverbeke <******@*********.******> and Source: https://github.com/lezer-parser/xml.git Link: https://github.com/lezer-parser/xml.git ----------- +@marijn/find-cluster-break +License: MIT +Source: git+https://github.com/marijnh/find-cluster-break.git +Link: https://github.com/marijnh/find-cluster-break#readme +----------- @nodelib/fs.scandir License: MIT License File: node_modules/@nodelib/fs.scandir/LICENSE @@ -3546,6 +3564,27 @@ Copyright: Copyright (c) Denis Malinochkin Source: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk Link: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.walk ----------- +@parcel/watcher-linux-x64-glibc +License: MIT +License File: node_modules/@parcel/watcher-linux-x64-glibc/LICENSE +Copyright: Copyright (c) 2017-present Devon Govett +Source: https://github.com/parcel-bundler/watcher.git +Link: https://github.com/parcel-bundler/watcher.git +----------- +@parcel/watcher-linux-x64-musl +License: MIT +License File: node_modules/@parcel/watcher-linux-x64-musl/LICENSE +Copyright: Copyright (c) 2017-present Devon Govett +Source: https://github.com/parcel-bundler/watcher.git +Link: https://github.com/parcel-bundler/watcher.git +----------- +@parcel/watcher +License: MIT +License File: node_modules/@parcel/watcher/LICENSE +Copyright: Copyright (c) 2017-present Devon Govett +Source: https://github.com/parcel-bundler/watcher.git +Link: https://github.com/parcel-bundler/watcher.git +----------- @rtsao/scc License: MIT License File: node_modules/@rtsao/scc/LICENSE diff --git a/dev/licensing/php-library-licenses.txt b/dev/licensing/php-library-licenses.txt index 747ccc986..bf3619036 100644 --- a/dev/licensing/php-library-licenses.txt +++ b/dev/licensing/php-library-licenses.txt @@ -202,7 +202,7 @@ Link: https://github.com/intervention/gif intervention/image License: MIT License File: vendor/intervention/image/LICENSE -Copyright: Copyright (c) 2013-2024 Oliver Vogel +Copyright: Copyright (c) 2013-present Oliver Vogel Source: https://github.com/Intervention/image.git Link: https://image.intervention.io/ ----------- @@ -307,7 +307,7 @@ Link: https://github.com/thephpleague/oauth1-client.git league/oauth2-client License: MIT License File: vendor/league/oauth2-client/LICENSE -Copyright: Copyright (c) 2013-2020 Alex Bilbie <*****@**********.***> +Copyright: Copyright (c) 2013-2023 Alex Bilbie <*****@**********.***> Source: https://github.com/thephpleague/oauth2-client.git Link: https://github.com/thephpleague/oauth2-client.git ----------- @@ -560,9 +560,9 @@ Link: https://github.com/SocialiteProviders/Twitch.git ssddanbrown/htmldiff License: MIT License File: vendor/ssddanbrown/htmldiff/license.md -Copyright: Copyright (c) 2020 Nathan Herald, Rohland de Charmoy, Dan Brown -Source: https://github.com/ssddanbrown/HtmlDiff.git -Link: https://github.com/ssddanbrown/htmldiff +Copyright: Copyright (c) 2024 Nathan Herald, Rohland de Charmoy, Dan Brown +Source: https://codeberg.org/danb/HtmlDiff +Link: https://codeberg.org/danb/HtmlDiff ----------- ssddanbrown/symfony-mailer License: MIT