diff --git a/package-lock.json b/package-lock.json index 64adf3c8b2..ee3618b282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8371,6 +8371,11 @@ "resolved": "services/contacts", "link": true }, + "node_modules/@overleaf/dictionaries": { + "version": "0.0.2", + "resolved": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz", + "integrity": "sha512-p8QJhmQcZ33PEPe0Lqi7NfFkA9IbZNOvlPk3URKFTeb3qAHW+s5+U9qEnHp1s3xr7ZOgu6D5YPK0fRNOE9+zmg==" + }, "node_modules/@overleaf/docstore": { "resolved": "services/docstore", "link": true @@ -43751,6 +43756,7 @@ "@node-oauth/oauth2-server": "^5.1.0", "@node-saml/passport-saml": "^4.0.4", "@overleaf/access-token-encryptor": "*", + "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz", "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", @@ -51353,6 +51359,10 @@ } } }, + "@overleaf/dictionaries": { + "version": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz", + "integrity": "sha512-p8QJhmQcZ33PEPe0Lqi7NfFkA9IbZNOvlPk3URKFTeb3qAHW+s5+U9qEnHp1s3xr7ZOgu6D5YPK0fRNOE9+zmg==" + }, "@overleaf/docstore": { "version": "file:services/docstore", "requires": { @@ -52361,6 +52371,7 @@ "@opentelemetry/semantic-conventions": "^1.15.2", "@overleaf/access-token-encryptor": "*", "@overleaf/codemirror-tree-view": "^0.1.3", + "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz", "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", diff --git a/services/web/.eslintignore b/services/web/.eslintignore index 54c37561b4..673c2a75e3 100644 --- a/services/web/.eslintignore +++ b/services/web/.eslintignore @@ -9,3 +9,4 @@ frontend/js/features/source-editor/lezer-latex/latex.mjs frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs +frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs diff --git a/services/web/.prettierignore b/services/web/.prettierignore index 3ab82e1eb6..f4be187b87 100644 --- a/services/web/.prettierignore +++ b/services/web/.prettierignore @@ -11,3 +11,4 @@ frontend/js/features/source-editor/lezer-latex/latex.mjs frontend/js/features/source-editor/lezer-latex/latex.terms.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs +frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index 35e17fdfe9..5cdc205320 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -79,6 +79,7 @@ async function joinProject(req, res, next) { // disable spellchecking for currently unsupported spell check languages // preserve the value in the db so they can use it again once we add back // support. + // TODO: allow these if in client-side spell check split test if ( unsupportedSpellcheckLanguages.indexOf(project.spellCheckLanguage) !== -1 ) { diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index c528c64036..5d149e9098 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -340,6 +340,7 @@ const _ProjectController = { 'ieee-stylesheet', 'write-and-cite', 'default-visual-for-beginners', + 'spell-check-client', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/app/src/Features/Spelling/SpellingController.js b/services/web/app/src/Features/Spelling/SpellingController.js index cdf28f36d9..64b7363637 100644 --- a/services/web/app/src/Features/Spelling/SpellingController.js +++ b/services/web/app/src/Features/Spelling/SpellingController.js @@ -7,7 +7,7 @@ const LearnedWordsManager = require('./LearnedWordsManager') const TEN_SECONDS = 1000 * 10 const languageCodeIsSupported = code => - Settings.languages.some(lang => lang.code === code) + Settings.languages.some(lang => lang.code === code && lang.server !== false) module.exports = { learn(req, res, next) { diff --git a/services/web/app/src/infrastructure/ExpressLocals.js b/services/web/app/src/infrastructure/ExpressLocals.js index 936453fe93..226d5efcaf 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.js +++ b/services/web/app/src/infrastructure/ExpressLocals.js @@ -174,6 +174,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) { } res.locals.mathJaxPath = `/js/libs/mathjax-${PackageVersions.version.mathjax}/es5/tex-svg-full.js` + res.locals.dictionariesRoot = `/js/dictionaries/${PackageVersions.version.dictionaries}/` res.locals.lib = PackageVersions.lib diff --git a/services/web/app/src/infrastructure/PackageVersions.js b/services/web/app/src/infrastructure/PackageVersions.js index bdf2ab54ec..56589ec36a 100644 --- a/services/web/app/src/infrastructure/PackageVersions.js +++ b/services/web/app/src/infrastructure/PackageVersions.js @@ -1,5 +1,6 @@ const version = { mathjax: '3.2.2', + dictionaries: '0.0.2', } module.exports = { diff --git a/services/web/app/views/layout-base.pug b/services/web/app/views/layout-base.pug index c9eec5fe6e..b567ada69a 100644 --- a/services/web/app/views/layout-base.pug +++ b/services/web/app/views/layout-base.pug @@ -47,6 +47,7 @@ html( //- See: https://webpack.js.org/guides/public-path/#on-the-fly meta(name="ol-baseAssetPath" content=buildBaseAssetPath()) meta(name="ol-mathJaxPath" content=mathJaxPath) + meta(name="ol-dictionariesRoot" content=dictionariesRoot) meta(name="ol-usersEmail" content=getUserEmail()) meta(name="ol-ab" data-type="json" content={}) diff --git a/services/web/bin/sentry_upload b/services/web/bin/sentry_upload index 309ea99345..a1badc66f3 100755 --- a/services/web/bin/sentry_upload +++ b/services/web/bin/sentry_upload @@ -8,10 +8,9 @@ if [[ "$BRANCH_NAME" == "master" || "$BRANCH_NAME" == "main" ]]; then cd sentry_upload/public SENTRY_RELEASE=${COMMIT_SHA} - OPTS="--no-rewrite --url-prefix ~" sentry-cli releases new "$SENTRY_RELEASE" sentry-cli releases set-commits --auto "$SENTRY_RELEASE" - sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps ${OPTS} . + sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps --ignore '*/dictionaries/*' --no-rewrite --url-prefix ~ . sentry-cli releases finalize "$SENTRY_RELEASE" rm -rf sentry_upload diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 311134d22c..c72c99e28a 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -428,58 +428,115 @@ module.exports = { }, // Spelling languages + // dic = available in client + // server: false = not available on server // ------------------ - // - // You must have the corresponding aspell package installed to - // be able to use a language. languages: [ { code: 'en', name: 'English' }, - { code: 'en_US', name: 'English (American)' }, - { code: 'en_GB', name: 'English (British)' }, - { code: 'en_CA', name: 'English (Canadian)' }, - { code: 'af', name: 'Afrikaans' }, - { code: 'ar', name: 'Arabic' }, - { code: 'gl', name: 'Galician' }, - { code: 'eu', name: 'Basque' }, - { code: 'br', name: 'Breton' }, - { code: 'bg', name: 'Bulgarian' }, - { code: 'ca', name: 'Catalan' }, - { code: 'hr', name: 'Croatian' }, - { code: 'cs', name: 'Czech' }, - { code: 'da', name: 'Danish' }, - { code: 'nl', name: 'Dutch' }, - { code: 'eo', name: 'Esperanto' }, - { code: 'et', name: 'Estonian' }, - { code: 'fo', name: 'Faroese' }, - { code: 'fr', name: 'French' }, - { code: 'de', name: 'German' }, - { code: 'el', name: 'Greek' }, - { code: 'id', name: 'Indonesian' }, - { code: 'ga', name: 'Irish' }, - { code: 'it', name: 'Italian' }, - { code: 'kk', name: 'Kazakh' }, + { code: 'en_US', dic: 'en_US', name: 'English (American)' }, + { code: 'en_GB', dic: 'en_GB', name: 'English (British)' }, + { code: 'en_CA', dic: 'en_CA', name: 'English (Canadian)' }, + { + code: 'en_AU', + dic: 'en_AU', + name: 'English (Australian)', + server: false, + }, + { + code: 'en_ZA', + dic: 'en_ZA', + name: 'English (South African)', + server: false, + }, + { code: 'af', dic: 'af_ZA', name: 'Afrikaans' }, + { code: 'an', dic: 'an_ES', name: 'Aragonese', server: false }, + { code: 'ar', dic: 'ar', name: 'Arabic' }, + { code: 'be_BY', dic: 'be_BY', name: 'Belarusian', server: false }, + { code: 'gl', dic: 'gl_ES', name: 'Galician' }, + { code: 'eu', dic: 'eu', name: 'Basque' }, + { code: 'bn_BD', dic: 'bn_BD', name: 'Bengali', server: false }, + { code: 'bs_BA', dic: 'bs_BA', name: 'Bosnian', server: false }, + { code: 'br', dic: 'br_FR', name: 'Breton' }, + { code: 'bg', dic: 'bg_BG', name: 'Bulgarian' }, + { code: 'ca', dic: 'ca', name: 'Catalan' }, + { code: 'hr', dic: 'hr_HR', name: 'Croatian' }, + { code: 'cs', dic: 'cs_CZ', name: 'Czech' }, + { + code: 'da', + // dic: 'da_DK', TODO: re-enable client spell check + name: 'Danish', + }, + { code: 'nl', dic: 'nl', name: 'Dutch' }, + { code: 'dz', dic: 'dz', name: 'Dzongkha', server: false }, + { code: 'eo', dic: 'eo', name: 'Esperanto' }, + { code: 'et', dic: 'et_EE', name: 'Estonian' }, + { code: 'fo', dic: 'fo', name: 'Faroese' }, + { code: 'fr', dic: 'fr', name: 'French' }, + { code: 'gl_ES', dic: 'gl_ES', name: 'Galician', server: false }, + { code: 'de', dic: 'de_DE', name: 'German' }, + { code: 'de_AT', dic: 'de_AT', name: 'German (Austria)', server: false }, + { + code: 'de_CH', + dic: 'de_CH', + name: 'German (Switzerland)', + server: false, + }, + { code: 'el', dic: 'el_GR', name: 'Greek' }, + { code: 'gug_PY', dic: 'gug_PY', name: 'Guarani', server: false }, + { code: 'gu_IN', dic: 'gu_IN', name: 'Gujarati', server: false }, + { code: 'he_IL', dic: 'he_IL', name: 'Hebrew', server: false }, + { code: 'hi_IN', dic: 'hi_IN', name: 'Hindi', server: false }, + { code: 'hu_HU', dic: 'hu_HU', name: 'Hungarian', server: false }, + { code: 'is_IS', dic: 'is_IS', name: 'Icelandic', server: false }, + { code: 'id', dic: 'id_ID', name: 'Indonesian' }, + { code: 'ga', dic: 'ga_IE', name: 'Irish' }, + { code: 'it', dic: 'it_IT', name: 'Italian' }, + { code: 'kk', dic: 'kk_KZ', name: 'Kazakh' }, + { code: 'ko', dic: 'ko', name: 'Korean', server: false }, { code: 'ku', name: 'Kurdish' }, - { code: 'lv', name: 'Latvian' }, - { code: 'lt', name: 'Lithuanian' }, + { code: 'kmr', dic: 'kmr_Latn', name: 'Kurmanji', server: false }, + { code: 'lv', dic: 'lv_LV', name: 'Latvian' }, + { code: 'lt', dic: 'lt_LT', name: 'Lithuanian' }, + { code: 'lo_LA', dic: 'lo_LA', name: 'Laotian', server: false }, + { code: 'ml_IN', dic: 'ml_IN', name: 'Malayalam', server: false }, + { code: 'mn_MN', dic: 'mn_MN', name: 'Mongolian', server: false }, { code: 'nr', name: 'Ndebele' }, + { code: 'ne_NP', dic: 'ne_NP', name: 'Nepali', server: false }, { code: 'ns', name: 'Northern Sotho' }, - { code: 'no', name: 'Norwegian' }, - { code: 'fa', name: 'Persian' }, - { code: 'pl', name: 'Polish' }, - { code: 'pt_BR', name: 'Portuguese (Brazilian)' }, - { code: 'pt_PT', name: 'Portuguese (European)' }, + { code: 'no', dic: 'nn_NO', name: 'Norwegian' }, + { code: 'oc_FR', dic: 'oc_FR', name: 'Occitan', server: false }, + { code: 'fa', dic: 'fa_IR', name: 'Persian' }, + { code: 'pl', dic: 'pl_PL', name: 'Polish' }, + { code: 'pt_BR', dic: 'pt_BR', name: 'Portuguese (Brazilian)' }, + { + code: 'pt_PT', + dic: 'pt_PT', + name: 'Portuguese (European)', + server: true, + }, { code: 'pa', name: 'Punjabi' }, - { code: 'ro', name: 'Romanian' }, - { code: 'ru', name: 'Russian' }, - { code: 'sk', name: 'Slovak' }, - { code: 'sl', name: 'Slovenian' }, + { code: 'ro', dic: 'ro_RO', name: 'Romanian' }, + { code: 'ru', dic: 'ru_RU', name: 'Russian' }, + { code: 'gd_GB', dic: 'gd_GB', name: 'Scottish Gaelic', server: false }, + { code: 'sr_RS', dic: 'sr_RS', name: 'Serbian', server: false }, + { code: 'si_LK', dic: 'si_LK', name: 'Sinhala', server: false }, + { code: 'sk', dic: 'sk_SK', name: 'Slovak' }, + { code: 'sl', dic: 'sl_SI', name: 'Slovenian' }, { code: 'st', name: 'Southern Sotho' }, - { code: 'es', name: 'Spanish' }, - { code: 'sv', name: 'Swedish' }, - { code: 'tl', name: 'Tagalog' }, + { code: 'es', dic: 'es_ES', name: 'Spanish' }, + { code: 'sw_TZ', dic: 'sw_TZ', name: 'Swahili', server: false }, + { code: 'sv', dic: 'sv_SE', name: 'Swedish' }, + { code: 'tl', dic: 'tl', name: 'Tagalog' }, + { code: 'te_IN', dic: 'te_IN', name: 'Telugu', server: false }, + { code: 'th_TH', dic: 'th_TH', name: 'Thai', server: false }, + { code: 'bo', dic: 'bo', name: 'Tibetan', server: false }, { code: 'ts', name: 'Tsonga' }, { code: 'tn', name: 'Tswana' }, + { code: 'tr_TR', dic: 'tr_TR', name: 'Turkish', server: false }, + { code: 'uk_UA', dic: 'uk_UA', name: 'Ukrainian', server: false }, { code: 'hsb', name: 'Upper Sorbian' }, + { code: 'uz_UZ', dic: 'uz_UZ', name: 'Uzbek', server: false }, + { code: 'vi_VN', dic: 'vi_VN', name: 'Vietnamese', server: false }, { code: 'cy', name: 'Welsh' }, { code: 'xh', name: 'Xhosa' }, ], diff --git a/services/web/cypress/support/webpack.cypress.ts b/services/web/cypress/support/webpack.cypress.ts index b210d80e5c..6930302b66 100644 --- a/services/web/cypress/support/webpack.cypress.ts +++ b/services/web/cypress/support/webpack.cypress.ts @@ -34,6 +34,12 @@ const buildConfig = () => { '../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker' ) + // add entrypoint under '/' for hunspell worker + addWorker( + 'hunspell-worker', + '../../frontend/js/features/source-editor/hunspell/hunspell.worker' + ) + // add entrypoints under '/' for pdfjs workers addWorker('pdfjs-dist213', 'pdfjs-dist213/legacy/build/pdf.worker.js') addWorker('pdfjs-dist401', 'pdfjs-dist401/legacy/build/pdf.worker.mjs') diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 5cc37e7b00..544108d058 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -65,6 +65,7 @@ "add_or_remove_project_from_tag": "", "add_people": "", "add_role_and_department": "", + "add_to_dictionary": "", "add_to_tag": "", "add_your_comment_here": "", "add_your_first_group_member_now": "", diff --git a/services/web/frontend/js/features/dictionary/ignored-words.ts b/services/web/frontend/js/features/dictionary/ignored-words.ts index d7b141656f..90230eefd2 100644 --- a/services/web/frontend/js/features/dictionary/ignored-words.ts +++ b/services/web/frontend/js/features/dictionary/ignored-words.ts @@ -1,6 +1,6 @@ import getMeta from '../../utils/meta' -const IGNORED_MISSPELLINGS = [ +export const globalLearnedWords = new Set([ 'Overleaf', 'overleaf', 'ShareLaTeX', @@ -21,15 +21,15 @@ const IGNORED_MISSPELLINGS = [ 'lockdown', 'Coronavirus', 'coronavirus', -] +]) export class IgnoredWords { public learnedWords!: Set - private ignoredMisspellings: Set + private readonly ignoredMisspellings: Set constructor() { this.reset() - this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS) + this.ignoredMisspellings = globalLearnedWords window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests } diff --git a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx index 240927cedb..9772a33a3f 100644 --- a/services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx +++ b/services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx @@ -4,25 +4,39 @@ import getMeta from '../../../../utils/meta' import { useProjectSettingsContext } from '../../context/project-settings-context' import SettingsMenuSelect from './settings-menu-select' import type { Optgroup } from './settings-menu-select' +import { useFeatureFlag } from '@/shared/context/split-test-context' + +// TODO: set to true when ready to show new languages that are only available in the client +const showClientOnlyLanguages = false export default function SettingsSpellCheckLanguage() { const { t } = useTranslation() const languages = getMeta('ol-languages') + const spellCheckClientEnabled = useFeatureFlag('spell-check-client') + const { spellCheckLanguage, setSpellCheckLanguage } = useProjectSettingsContext() - const optgroup: Optgroup = useMemo( - () => ({ + const optgroup: Optgroup = useMemo(() => { + const options = (languages ?? []).filter(lang => { + const clientOnly = lang.server === false + + if (clientOnly && !showClientOnlyLanguages) { + return false + } + + return spellCheckClientEnabled || !clientOnly + }) + + return { label: 'Language', - options: - languages?.map(language => ({ - value: language.code, - label: language.name, - })) ?? [], - }), - [languages] - ) + options: options.map(language => ({ + value: language.code, + label: language.name, + })), + } + }, [languages, spellCheckClientEnabled]) return ( { - if (Date.now() < openingUntil) { - return - } - - if (view.state.field(spellingMenuField, false)) { - view.dispatch({ - effects: hideSpellingMenu.of(null), - }) - } -} - -/* - * Detect when the user right-clicks on a misspelled word, - * and show a menu of suggestions - */ -const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => { - const position = view.posAtCoords( - { - x: event.pageX, - y: event.pageY, - }, - false - ) - const targetMark = getMarkAtPosition(view, position) - - if (!targetMark) { - return - } - - const { from, to, value } = targetMark - - const targetWord = value.spec.word - if (!targetWord) { - debugConsole.debug( - '>> spelling no word associated with decorated range, stopping' - ) - return - } - - event.preventDefault() - - openingUntil = Date.now() + 100 - - view.dispatch({ - selection: EditorSelection.range(from, to), - effects: showSpellingMenu.of({ - mark: targetMark, - word: targetWord, - }), - }) -} - -const handleShortcutEvent = (view: EditorView) => { - const targetMark = getMarkAtPosition(view, view.state.selection.main.from) - - if (!targetMark || !targetMark.value) { - return false - } - - view.dispatch({ - selection: EditorSelection.range(targetMark.from, targetMark.to), - effects: showSpellingMenu.of({ - mark: targetMark, - word: targetMark.value.spec.word, - }), - }) - - return true -} - -/* - * Spelling menu "tooltip" field. - * Manages the menu of suggestions shown on right-click - */ -export const spellingMenuField = StateField.define({ - create() { - return null - }, - update(value, transaction) { - if (value) { - value = { - ...value, - pos: transaction.changes.mapPos(value.pos), - end: value.end ? transaction.changes.mapPos(value.end) : undefined, - } - } - - for (const effect of transaction.effects) { - if (effect.is(hideSpellingMenu)) { - value = null - } else if (effect.is(showSpellingMenu)) { - const { mark, word } = effect.value - // Build a "Tooltip" showing the suggestions - value = { - pos: mark.from, - end: mark.to, - above: false, - strictSide: false, - create: view => { - return createSpellingSuggestionList(word, view) - }, - } - } - } - return value - }, - provide: field => { - return [ - showTooltip.from(field), - EditorView.domEventHandlers({ - contextmenu: handleContextMenuEvent, - click: handleClickEvent, - }), - Prec.highest( - keymap.of([ - { key: 'Ctrl-Space', run: handleShortcutEvent }, - { key: 'Alt-Space', run: handleShortcutEvent }, - ]) - ), - ] - }, -}) - -const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>() - -const hideSpellingMenu = StateEffect.define() - -/* - * Creates the suggestion menu dom, to be displayed in the - * spelling menu "tooltip" - * */ -const createSpellingSuggestionList = (word: Word, view: EditorView) => { - // Wrapper div. - // Note, CM6 doesn't like showing complex elements - // 'inside' its Tooltip element, so we style this - // wrapper div to be basically invisible, and allow - // the dropdown list to hang off of it, giving the illusion that - // the list _is_ the tooltip. - // See the theme in spelling/index for styling that makes this work. - const dom = document.createElement('div') - dom.classList.add('ol-cm-spelling-context-menu-tooltip') - - // List - const list = document.createElement('ul') - list.setAttribute('tabindex', '0') - list.setAttribute('role', 'menu') - list.addEventListener('keydown', event => { - if (event.code === 'Tab') { - // preventing selecting next element - event.preventDefault() - } - }) - list.addEventListener('keyup', event => { - switch (event.code) { - case 'ArrowDown': { - // get currently selected option - const selectedButton = - list.querySelector('li button:focus') - - if (!selectedButton) { - return list - .querySelector('li[role="option"] button') - ?.focus() - } - - // get next option - let nextElement = selectedButton.parentElement?.nextElementSibling - if (nextElement?.getAttribute('role') !== 'option') { - nextElement = nextElement?.nextElementSibling - } - nextElement?.querySelector('button')?.focus() - break - } - case 'ArrowUp': { - // get currently selected option - const selectedButton = - list.querySelector('li button:focus') - - if (!selectedButton) { - return list - .querySelector( - 'li[role="option"]:last-child button' - ) - ?.focus() - } - - // get previous option - let previousElement = - selectedButton.parentElement?.previousElementSibling - if (previousElement?.getAttribute('role') !== 'option') { - previousElement = previousElement?.previousElementSibling - } - previousElement?.querySelector('button')?.focus() - break - } - case 'Escape': - case 'Tab': { - view.dispatch({ - effects: hideSpellingMenu.of(null), - }) - view.focus() - break - } - } - }) - - list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned') - - // List items, with links inside - if (Array.isArray(word.suggestions)) { - for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) { - const li = makeLinkItem(suggestion, event => { - const text = (event.target as HTMLElement).innerText - handleCorrectWord(word, text, view) - event.preventDefault() - }) - list.appendChild(li) - } - } - - setTimeout(() => { - list.querySelector('li:first-child button')?.focus() - }, 0) - - // Divider - const divider = document.createElement('li') - divider.classList.add('divider') - list.append(divider) - - // Add to Dictionary - const addToDictionary = makeLinkItem( - 'Add to Dictionary', - async function (event: Event) { - await handleLearnWord(word, view) - event.preventDefault() - } - ) - list.append(addToDictionary) - - dom.appendChild(list) - return { dom } -} - -const makeLinkItem = (suggestion: string, handler: EventListener) => { - const li = document.createElement('li') - const button = document.createElement('button') - li.setAttribute('role', 'option') - button.classList.add('btn-link', 'text-left', 'dropdown-menu-button') - button.onclick = handler - button.textContent = suggestion - li.appendChild(button) - return li -} - -/* - * Learn a word, adding it to the local cache - * and sending it to the spelling backend - */ -const handleLearnWord = async function (word: Word, view: EditorView) { - try { - await learnWordRequest(word) - view.dispatch({ - effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)], - }) - } catch (err) { - debugConsole.error(err) - } -} - -/* - * Correct a word, removing the marked range - * and replacing it with the chosen text - */ -const handleCorrectWord = (word: Word, text: string, view: EditorView) => { - const tooltip = view.state.field(spellingMenuField) - if (!tooltip) { - throw new Error('No active tooltip') - } - const existingText = view.state.doc.sliceString(tooltip.pos, tooltip.end) - // Defend against erroneous replacement, if the word at this - // position is not actually what we think it is - if (existingText !== word.text) { - debugConsole.debug( - '>> spelling word-to-correct does not match, stopping', - tooltip.pos, - tooltip.end, - existingText, - word - ) - return - } - view.dispatch({ - changes: [ - { - from: tooltip.pos, - to: tooltip.end, - insert: text, - }, - ], - effects: [hideSpellingMenu.of(null)], - }) - view.focus() -} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx new file mode 100644 index 0000000000..1f7d2b4bfd --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx @@ -0,0 +1,224 @@ +import { + StateField, + StateEffect, + EditorSelection, + Prec, +} from '@codemirror/state' +import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view' +import { addIgnoredWord } from './ignored-words' +import { learnWordRequest } from './backend' +import { Word, Mark, getMarkAtPosition } from './spellchecker' +import { debugConsole } from '@/utils/debugging' +import { + getSpellChecker, + getSpellCheckLanguage, +} from '@/features/source-editor/extensions/spelling/index' +import { sendMB } from '@/infrastructure/event-tracking' +import ReactDOM from 'react-dom' +import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions' +import { SplitTestProvider } from '@/shared/context/split-test-context' + +/* + * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu. + * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener. + */ +let openingUntil = 0 + +/* + * Hide the spelling menu on click + */ +const handleClickEvent = (event: MouseEvent, view: EditorView) => { + if (Date.now() < openingUntil) { + return + } + + if (view.state.field(spellingMenuField, false)) { + view.dispatch({ + effects: hideSpellingMenu.of(null), + }) + } +} + +/* + * Detect when the user right-clicks on a misspelled word, + * and show a menu of suggestions + */ +const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => { + const position = view.posAtCoords( + { + x: event.pageX, + y: event.pageY, + }, + false + ) + const targetMark = getMarkAtPosition(view, position) + + if (!targetMark) { + return + } + + const { from, to, value } = targetMark + + const targetWord = value.spec.word + if (!targetWord) { + debugConsole.debug( + '>> spelling no word associated with decorated range, stopping' + ) + return + } + + event.preventDefault() + + openingUntil = Date.now() + 100 + + view.dispatch({ + selection: EditorSelection.range(from, to), + effects: showSpellingMenu.of({ + mark: targetMark, + word: targetWord, + }), + }) +} + +const handleShortcutEvent = (view: EditorView) => { + const targetMark = getMarkAtPosition(view, view.state.selection.main.from) + + if (!targetMark || !targetMark.value) { + return false + } + + view.dispatch({ + selection: EditorSelection.range(targetMark.from, targetMark.to), + effects: showSpellingMenu.of({ + mark: targetMark, + word: targetMark.value.spec.word, + }), + }) + + return true +} + +/* + * Spelling menu "tooltip" field. + * Manages the menu of suggestions shown on right-click + */ +export const spellingMenuField = StateField.define({ + create() { + return null + }, + update(value, transaction) { + if (value) { + value = { + ...value, + pos: transaction.changes.mapPos(value.pos), + end: value.end ? transaction.changes.mapPos(value.end) : undefined, + } + } + + for (const effect of transaction.effects) { + if (effect.is(hideSpellingMenu)) { + value = null + } else if (effect.is(showSpellingMenu)) { + const { mark, word } = effect.value + // Build a "Tooltip" showing the suggestions + value = { + pos: mark.from, + end: mark.to, + above: false, + strictSide: false, + create: createSpellingSuggestionList(word), + } + } + } + return value + }, + provide: field => { + return [ + showTooltip.from(field), + EditorView.domEventHandlers({ + contextmenu: handleContextMenuEvent, + click: handleClickEvent, + }), + Prec.highest( + keymap.of([ + { key: 'Ctrl-Space', run: handleShortcutEvent }, + { key: 'Alt-Space', run: handleShortcutEvent }, + ]) + ), + ] + }, +}) + +const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>() + +export const hideSpellingMenu = StateEffect.define() + +/* + * Creates the suggestion menu dom, to be displayed in the + * spelling menu "tooltip" + * */ +const createSpellingSuggestionList = (word: Word) => (view: EditorView) => { + const dom = document.createElement('div') + dom.classList.add('ol-cm-spelling-context-menu-tooltip') + + ReactDOM.render( + + { + view.dispatch({ + effects: hideSpellingMenu.of(null), + }) + view.focus() + }} + handleLearnWord={() => { + learnWordRequest(word) + .then(() => { + view.dispatch({ + effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)], + }) + sendMB('spelling-word-added', { + language: getSpellCheckLanguage(view.state), + }) + }) + .catch(error => { + debugConsole.error(error) + }) + }} + handleCorrectWord={(text: string) => { + const tooltip = view.state.field(spellingMenuField) + if (!tooltip) { + throw new Error('No active tooltip') + } + + const existingText = view.state.doc.sliceString( + tooltip.pos, + tooltip.end + ) + if (existingText !== word.text) { + return + } + + view.dispatch({ + changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }], + effects: [hideSpellingMenu.of(null)], + }) + view.focus() + + sendMB('spelling-suggestion-click', { + language: getSpellCheckLanguage(view.state), + }) + }} + /> + , + dom + ) + + const destroy = () => { + ReactDOM.unmountComponentAtNode(dom) + } + + return { dom, destroy } +} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts index 69105e3c43..1a17451d7d 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts @@ -20,6 +20,12 @@ export const addIgnoredWord = StateEffect.define<{ text: string }>() +export const removeIgnoredWord = StateEffect.define<{ + text: string +}>() + export const updateAfterAddingIgnoredWord = StateEffect.define() +export const updateAfterRemovingIgnoredWord = StateEffect.define() + export const resetSpellChecker = StateEffect.define() diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts index aa8970b611..345a357a76 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/index.ts @@ -1,26 +1,29 @@ import { EditorView, ViewPlugin } from '@codemirror/view' import { - Compartment, - Facet, + EditorState, StateEffect, StateField, TransactionSpec, } from '@codemirror/state' -import { misspelledWordsField, resetMisspelledWords } from './misspelled-words' +import { misspelledWordsField } from './misspelled-words' import { + addIgnoredWord, ignoredWordsField, + removeIgnoredWord, resetSpellChecker, updateAfterAddingIgnoredWord, } from './ignored-words' import { addWordToCache, cacheField, removeWordFromCache } from './cache' -import { spellingMenuField } from './context-menu' +import { hideSpellingMenu, spellingMenuField } from './context-menu' import { SpellChecker } from './spellchecker' import { parserWatcher } from '../wait-for-parser' +import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager' +import { debugConsole } from '@/utils/debugging' -const spellCheckLanguageConf = new Compartment() -const spellCheckLanguageFacet = Facet.define() - -type Options = { spellCheckLanguage?: string } +type Options = { + spellCheckLanguage?: string + hunspellManager?: HunspellManager +} /** * A custom extension that creates a spell checker for the current language (from the user settings). @@ -29,12 +32,16 @@ type Options = { spellCheckLanguage?: string } * Mis-spelled words are decorated with a Mark decoration. * The suggestions menu is displayed in a tooltip, activated with a right-click on the decoration. */ -export const spelling = ({ spellCheckLanguage }: Options) => { +export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => { return [ spellingTheme, parserWatcher, - spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)), - spellCheckField, + spellCheckLanguageField.init(() => spellCheckLanguage), + spellCheckerField.init(() => + spellCheckLanguage + ? new SpellChecker(spellCheckLanguage, hunspellManager) + : null + ), misspelledWordsField, ignoredWordsField, cacheField, @@ -56,16 +63,27 @@ const spellingTheme = EditorView.baseTheme({ }, }) -const spellCheckField = StateField.define({ - create(state) { - const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet) - return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null +export const getSpellChecker = (state: EditorState) => + state.field(spellCheckerField, false) + +const spellCheckerField = StateField.define({ + create() { + return null }, update(value, tr) { for (const effect of tr.effects) { if (effect.is(setSpellCheckLanguageEffect)) { value?.destroy() - return effect.value ? new SpellChecker(effect.value) : null + value = effect.value.spellCheckLanguage + ? new SpellChecker( + effect.value.spellCheckLanguage, + effect.value.hunspellManager + ) + : null + } else if (effect.is(addIgnoredWord)) { + value?.addWord(effect.value.text).catch(debugConsole.error) + } else if (effect.is(removeIgnoredWord)) { + value?.removeWord(effect.value.text).catch(debugConsole.error) } } return value @@ -80,7 +98,7 @@ const spellCheckField = StateField.define({ } }), EditorView.domEventHandlers({ - focus: (event, view) => { + focus: (_event, view) => { if (view.state.facet(EditorView.editable)) { view.state.field(field)?.scheduleSpellCheck(view) } @@ -95,18 +113,39 @@ const spellCheckField = StateField.define({ }, }) -const setSpellCheckLanguageEffect = StateEffect.define() +export const getSpellCheckLanguage = (state: EditorState) => + state.field(spellCheckLanguageField, false) -export const setSpelling = ({ +const spellCheckLanguageField = StateField.define({ + create() { + return undefined + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(setSpellCheckLanguageEffect)) { + value = effect.value.spellCheckLanguage + } + } + return value + }, +}) + +export const setSpellCheckLanguageEffect = StateEffect.define<{ + spellCheckLanguage: string | undefined + hunspellManager?: HunspellManager +}>() + +export const setSpellCheckLanguage = ({ spellCheckLanguage, + hunspellManager, }: Options): TransactionSpec => { return { effects: [ - resetMisspelledWords.of(null), - spellCheckLanguageConf.reconfigure( - spellCheckLanguageFacet.of(spellCheckLanguage) - ), - setSpellCheckLanguageEffect.of(spellCheckLanguage), + setSpellCheckLanguageEffect.of({ + spellCheckLanguage, + hunspellManager, + }), + hideSpellingMenu.of(null), ], } } @@ -137,6 +176,7 @@ export const removeLearnedWord = ( lang: spellCheckLanguage, wordText: word, }), + removeIgnoredWord.of({ text: word }), ], } } diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts index ce052842c0..66240c9074 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts @@ -2,11 +2,10 @@ import { StateField, StateEffect } from '@codemirror/state' import { EditorView, Decoration, DecorationSet } from '@codemirror/view' import { updateAfterAddingIgnoredWord } from './ignored-words' import { Word } from './spellchecker' +import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index' export const addMisspelledWords = StateEffect.define() -export const resetMisspelledWords = StateEffect.define() - const createMark = (word: Word) => { return Decoration.mark({ class: 'ol-cm-spelling-error', @@ -39,14 +38,17 @@ export const misspelledWordsField = StateField.define({ if (effect.is(addMisspelledWords)) { // Merge the new misspelled words into the existing set of marks marks = marks.update({ - add: effect.value.map(word => createMark(word)), + add: effect.value.map(word => createMark(word)), // TODO: make sure these positions are still accurate sort: true, }) } else if (effect.is(updateAfterAddingIgnoredWord)) { - // Remove a misspelled word, all instances that match text - const word = effect.value - marks = removeAllMarksMatchingWordText(marks, word) - } else if (effect.is(resetMisspelledWords)) { + // Remove existing marks matching the text of a supplied word + marks = marks.update({ + filter(_from, _to, mark) { + return mark.spec.word.text !== effect.value + }, + }) + } else if (effect.is(setSpellCheckLanguageEffect)) { marks = Decoration.none } } @@ -56,14 +58,3 @@ export const misspelledWordsField = StateField.define({ return EditorView.decorations.from(field) }, }) - -/* - * Remove existing marks matching the text of a supplied word - */ -const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => { - return marks.update({ - filter: (from, to, mark) => { - return mark.spec.word.text !== word - }, - }) -} diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts index d32a90ff59..e90f98da1b 100644 --- a/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts @@ -13,6 +13,7 @@ import { } from '../../utils/tree-query' import { waitForParser } from '../wait-for-parser' import { debugConsole } from '@/utils/debugging' +import type { HunspellManager } from '../../hunspell/HunspellManager' /* * Spellchecker, handles updates, schedules spelling checks @@ -26,13 +27,17 @@ export class SpellChecker { private trackedChanges: ChangeSet // eslint-disable-next-line no-useless-constructor - constructor(private readonly language: string) { - this.language = language + constructor( + private readonly language: string, + private hunspellManager?: HunspellManager + ) { + debugConsole.log('SpellChecker', language, hunspellManager) this.trackedChanges = ChangeSet.empty(0) } destroy() { this._clearPendingSpellCheck() + // this.hunspellManager?.destroy() } _abortRequest() { @@ -86,7 +91,7 @@ export class SpellChecker { wordsToCheck ) const processResult = ( - misspellings: { index: number; suggestions: string[] }[] + misspellings: { index: number; suggestions?: string[] }[] ) => { this.trackedChanges = ChangeSet.empty(0) @@ -108,18 +113,79 @@ export class SpellChecker { } else { this._abortRequest() this.abortController = new AbortController() - spellCheckRequest(this.language, unknownWords, this.abortController) - .then(result => { - this.abortController = null - processResult(result.misspellings) - }) - .catch(error => { - this.abortController = null - debugConsole.error(error) - }) + if (this.hunspellManager) { + const signal = this.abortController.signal + this.hunspellManager.send( + { + type: 'spell', + words: unknownWords.map(word => word.text), + }, + (result: any) => { + if (!signal.aborted) { + if (result.error) { + debugConsole.error(result.error) + } else { + processResult(result.misspellings) + } + } + } + ) + } else { + spellCheckRequest(this.language, unknownWords, this.abortController) + .then(result => { + this.abortController = null + return processResult(result.misspellings) + }) + .catch(error => { + this.abortController = null + debugConsole.error(error) + }) + } } } + suggest(word: string) { + return new Promise<{ suggestions: string[] }>((resolve, reject) => { + if (this.hunspellManager) { + this.hunspellManager.send({ type: 'suggest', word }, result => { + if ((result as { error: true }).error) { + reject(new Error()) + } else { + resolve(result as { suggestions: string[] }) + } + }) + } + }) + } + + addWord(word: string) { + return new Promise((resolve, reject) => { + if (this.hunspellManager) { + this.hunspellManager.send({ type: 'add_word', word }, result => { + if ((result as { error: true }).error) { + reject(new Error()) + } else { + resolve() + } + }) + } + }) + } + + removeWord(word: string) { + return new Promise((resolve, reject) => { + if (this.hunspellManager) { + this.hunspellManager.send({ type: 'remove_word', word }, result => { + if ((result as { error: true }).error) { + reject(new Error()) + } else { + resolve() + } + }) + } + }) + } + _spellCheckWhenParserReady(view: EditorView) { if (this.waitingForParser) { return @@ -163,7 +229,7 @@ export class SpellChecker { const { from, to } = view.viewport const changedLineNumbers = new Set() if (this.trackedChanges.length > 0) { - this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => { + this.trackedChanges.iterChangedRanges((_fromA, _toA, fromB, toB) => { if (fromB <= to && toB >= from) { const fromLine = view.state.doc.lineAt(fromB).number const toLine = view.state.doc.lineAt(toB).number @@ -180,7 +246,9 @@ export class SpellChecker { } } - const ignoredWords = view.state.field(ignoredWordsField) + const ignoredWords = this.hunspellManager + ? null + : view.state.field(ignoredWordsField) for (const i of changedLineNumbers) { const line = view.state.doc.line(i) wordsToCheck.push( @@ -228,7 +296,7 @@ export class Word { export const buildSpellCheckResult = ( knownMisspelledWords: Word[], unknownWords: Word[], - misspellings: { index: number; suggestions: string[] }[] + misspellings: { index: number; suggestions?: string[] }[] ) => { const cacheAdditions: [Word, string[] | boolean][] = [] @@ -277,7 +345,7 @@ export const compileEffects = (results: { export const getWordsFromLine = ( view: EditorView, line: Line, - ignoredWords: IgnoredWords, + ignoredWords: IgnoredWords | null, lang: string ): Word[] => { const normalTextSpans: Array = getNormalTextSpansFromLine( @@ -287,14 +355,8 @@ export const getWordsFromLine = ( const words: Word[] = [] for (const span of normalTextSpans) { for (const match of span.text.matchAll(WORD_REGEX)) { - let word = match[0] - if (word.startsWith("'")) { - word = word.slice(1) - } - if (word.endsWith("'")) { - word = word.slice(0, -1) - } - if (!ignoredWords.has(word)) { + const word = match[0].replace(/^'+/, '').replace(/'+$/, '') + if (!ignoredWords?.has(word)) { const from = span.from + match.index words.push( new Word({ diff --git a/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx b/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx new file mode 100644 index 0000000000..d3ab0644f7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx @@ -0,0 +1,189 @@ +import { + FC, + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { SpellChecker, Word } from './spellchecker' +import { useTranslation } from 'react-i18next' +import SplitTestBadge from '@/shared/components/split-test-badge' +import getMeta from '@/utils/meta' +import classnames from 'classnames' +import { SpellCheckLanguage } from '../../../../../../types/project-settings' +import Icon from '@/shared/components/icon' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import { sendMB } from '@/infrastructure/event-tracking' + +const ITEMS_TO_SHOW = 8 + +// TODO: messaging below the spelling suggestions +const SHOW_FOOTER = false + +// (index % length) that works for negative index +const wrapArrayIndex = (index: number, length: number) => + ((index % length) + length) % length + +export const SpellingSuggestions: FC<{ + word: Word + spellCheckLanguage?: string + spellChecker?: SpellChecker | null + handleClose: () => void + handleLearnWord: () => void + handleCorrectWord: (text: string) => void +}> = ({ + word, + spellCheckLanguage, + spellChecker, + handleClose, + handleLearnWord, + handleCorrectWord, +}) => { + const { t } = useTranslation() + + const [suggestions, setSuggestions] = useState(() => + Array.isArray(word.suggestions) + ? word.suggestions.slice(0, ITEMS_TO_SHOW) + : [] + ) + + const [waiting, setWaiting] = useState(!word.suggestions) + + const [selectedIndex, setSelectedIndex] = useState(0) + + const itemsLength = suggestions.length + 1 + + useEffect(() => { + if (!word.suggestions) { + spellChecker?.suggest(word.text).then(result => { + setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW)) + setWaiting(false) + sendMB('spelling-suggestion-shown', { + language: spellCheckLanguage, + count: result.suggestions.length, + // word: transaction.state.sliceDoc(mark.from, mark.to), + }) + }) + } + }, [word, spellChecker, spellCheckLanguage]) + + const language = useMemo(() => { + if (spellCheckLanguage) { + return getMeta('ol-languages').find( + item => item.code === spellCheckLanguage + ) + } + }, [spellCheckLanguage]) + + return ( +
    { + switch (event.code) { + case 'ArrowDown': + setSelectedIndex(value => wrapArrayIndex(value + 1, itemsLength)) + break + + case 'ArrowUp': + setSelectedIndex(value => wrapArrayIndex(value - 1, itemsLength)) + break + + case 'Escape': + case 'Tab': + event.preventDefault() + handleClose() + break + } + }} + > + {Array.isArray(suggestions) && ( + <> + {suggestions.map((suggestion, index) => ( + { + event.preventDefault() + handleCorrectWord(suggestion) + }} + /> + ))} + {suggestions.length > 0 &&
  • } + + )} + { + event.preventDefault() + handleLearnWord() + }} + /> + {SHOW_FOOTER && language && ( + <> +
  • +
  • +
    +
  • + + )} +
+ ) +} + +const Footer: FC<{ language: SpellCheckLanguage }> = ({ language }) => { + const spellCheckClientEnabled = useFeatureFlag('spell-check-client') + + if (!spellCheckClientEnabled) { + return null + } + + return language.dic ? ( +
+ {' '} + {language?.name} +
+ ) : ( +
+ {language?.name} +
+ ) +} + +const ListItem: FC<{ + content: string + selected: boolean + handleClick: MouseEventHandler +}> = ({ content, selected, handleClick }) => { + const handleListItem = useCallback( + (element: HTMLElement | null) => { + if (element && selected) { + window.setTimeout(() => { + element.focus() + }) + } + }, + [selected] + ) + + return ( +
  • + +
  • + ) +} diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts index 977534d922..aafab8eb19 100644 --- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts +++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts @@ -37,7 +37,7 @@ import { addLearnedWord, removeLearnedWord, resetLearnedWords, - setSpelling, + setSpellCheckLanguage, } from '../extensions/spelling' import { createChangeManager, @@ -65,6 +65,7 @@ import { setMathPreview } from '@/features/source-editor/extensions/math-preview import { useRangesContext } from '@/features/review-panel-new/context/ranges-context' import { updateRanges } from '@/features/source-editor/extensions/ranges' import { useThreadsContext } from '@/features/review-panel-new/context/threads-context' +import { useHunspell } from '@/features/source-editor/hooks/use-hunspell' function useCodeMirrorScope(view: EditorView) { const { fileTreeData } = useFileTreeData() @@ -111,6 +112,8 @@ function useCodeMirrorScope(view: EditorView) { 'project.spellCheckLanguage' ) + const hunspellManager = useHunspell(spellCheckLanguage) + const [visual] = useScopeValue('editor.showVisual') const { referenceKeys } = useReferencesContext() @@ -214,14 +217,16 @@ function useCodeMirrorScope(view: EditorView) { const spellingRef = useRef({ spellCheckLanguage, + hunspellManager, }) useEffect(() => { spellingRef.current = { spellCheckLanguage, + hunspellManager, } - view.dispatch(setSpelling(spellingRef.current)) - }, [view, spellCheckLanguage]) + view.dispatch(setSpellCheckLanguage(spellingRef.current)) + }, [view, spellCheckLanguage, hunspellManager]) // listen to doc:after-opened, and focus the editor if it's not a new doc useEffect(() => { diff --git a/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts b/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts new file mode 100644 index 0000000000..5a10b1dfcb --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts @@ -0,0 +1,35 @@ +import { isSplitTestEnabled } from '@/utils/splitTestUtils' +import { useEffect, useState } from 'react' +import getMeta from '@/utils/meta' +import { globalLearnedWords } from '@/features/dictionary/ignored-words' +import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager' +import { debugConsole } from '@/utils/debugging' + +export const useHunspell = (spellCheckLanguage: string | null) => { + const [hunspellManager, setHunspellManager] = useState() + + useEffect(() => { + if (isSplitTestEnabled('spell-check-client')) { + if (spellCheckLanguage) { + const languages = getMeta('ol-languages') + const lang = languages.find(item => item.code === spellCheckLanguage) + if (lang?.dic) { + const hunspellManager = new HunspellManager(lang.dic, [ + ...globalLearnedWords, + ...getMeta('ol-learnedWords'), + ]) + setHunspellManager(hunspellManager) + debugConsole.log(spellCheckLanguage, hunspellManager) + + return () => { + hunspellManager.destroy() + } + } else { + setHunspellManager(undefined) + } + } + } + }, [spellCheckLanguage]) + + return hunspellManager +} diff --git a/services/web/frontend/js/features/source-editor/hunspell/Dockerfile b/services/web/frontend/js/features/source-editor/hunspell/Dockerfile new file mode 100644 index 0000000000..6ad77a84df --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/Dockerfile @@ -0,0 +1,9 @@ +FROM emscripten/emsdk:3.1.67 + +RUN apt-get update && apt-get install -y \ + autoconf \ + automake \ + autopoint \ + build-essential \ + libtool \ + cmake diff --git a/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts b/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts new file mode 100644 index 0000000000..81f9ea13af --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts @@ -0,0 +1,111 @@ +import { v4 as uuid } from 'uuid' +import { createWorker } from '@/utils/worker' +import getMeta from '@/utils/meta' +import { debugConsole } from '@/utils/debugging' + +type Message = + | { + id?: string + type: 'spell' + words: string[] + } + | { + id?: string + type: 'suggest' + word: string + } + | { + id?: string + type: 'add_word' + word: string + } + | { + id?: string + type: 'remove_word' + word: string + } + | { + id?: string + type: 'destroy' + } + +export class HunspellManager { + dictionariesRoot: string + hunspellWorker!: Worker + abortController: AbortController | undefined + listening = false + loaded = false + pendingMessages: Message[] = [] + callbacks: Map void> = new Map() + + constructor( + private readonly language: string, + private readonly learnedWords: string[] + ) { + const baseAssetPath = new URL( + getMeta('ol-baseAssetPath'), + window.location.href + ) + + this.dictionariesRoot = new URL( + getMeta('ol-dictionariesRoot'), + baseAssetPath + ).toString() + + createWorker(() => { + this.hunspellWorker = new Worker( + new URL('./hunspell.worker.ts', import.meta.url), + { type: 'module' } + ) + + this.hunspellWorker.addEventListener('message', this.receive.bind(this)) + }) + } + + destroy() { + this.send({ type: 'destroy' }, () => { + this.hunspellWorker.terminate() + }) + } + + send(message: Message, callback: (value: unknown) => void) { + debugConsole.log(message) + if (callback) { + message.id = uuid() + this.callbacks.set(message.id, callback) + } + + if (this.listening) { + this.hunspellWorker.postMessage(message) + } else { + this.pendingMessages.push(message) + } + } + + receive(event: MessageEvent) { + debugConsole.log(event.data) + const { id, listening, loaded, ...rest } = event.data + if (id) { + const callback = this.callbacks.get(id) + if (callback) { + this.callbacks.delete(id) + callback(rest) + } + } else if (listening) { + this.listening = true + this.hunspellWorker.postMessage({ + type: 'init', + lang: this.language, + learnedWords: this.learnedWords, // TODO: add words + dictionariesRoot: this.dictionariesRoot, + }) + for (const message of this.pendingMessages) { + this.hunspellWorker.postMessage(message) + this.pendingMessages.length = 0 + } + } else if (loaded) { + this.loaded = true + // TODO: use this to display pending state? + } + } +} diff --git a/services/web/frontend/js/features/source-editor/hunspell/build.sh b/services/web/frontend/js/features/source-editor/hunspell/build.sh new file mode 100755 index 0000000000..8c19c197ea --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/build.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -e + +# build an Emscripten SDK Docker image with Hunspell's build dependencies installed +docker build --pull --tag overleaf/emsdk . + +# compile Hunspell to WASM and copy the output files from the Docker container +docker run --rm \ + --workdir /opt \ + --volume "$(pwd)/wasm":/wasm \ + --volume "$(pwd)/compile.sh":/opt/compile.sh:ro \ + overleaf/emsdk \ + bash compile.sh diff --git a/services/web/frontend/js/features/source-editor/hunspell/compile.sh b/services/web/frontend/js/features/source-editor/hunspell/compile.sh new file mode 100755 index 0000000000..e14639cf65 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/compile.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -e + +COMMIT="e994dceb97fb695bca6bfe5ad5665525426bf01f" + +curl -L "https://github.com/hunspell/hunspell/archive/${COMMIT}.tar.gz" | tar xvz + +cd "hunspell-${COMMIT}" +autoreconf -fiv +emconfigure ./configure --disable-shared --enable-static +emmake make + +em++ \ + -s EXPORTED_FUNCTIONS="['_Hunspell_create', '_Hunspell_destroy', '_Hunspell_spell', '_Hunspell_suggest', '_Hunspell_free_list', '_Hunspell_add_dic', '_Hunspell_add', '_Hunspell_remove', '_free', '_malloc', 'FS']" \ + -s EXPORTED_RUNTIME_METHODS="['ccall', 'cwrap', 'getValue', 'stringToNewUTF8', 'UTF8ToString', 'WORKERFS']" \ + -s ENVIRONMENT=worker \ + -s ALLOW_MEMORY_GROWTH \ + -lworkerfs.js \ + -O2 \ + -g2 \ + src/hunspell/.libs/libhunspell-1.7.a \ + -o hunspell.mjs + +cp hunspell.{mjs,wasm} /wasm/ diff --git a/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts b/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts new file mode 100644 index 0000000000..db5dad7848 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts @@ -0,0 +1,237 @@ +import Hunspell from './wasm/hunspell' + +type SpellChecker = { + spell(words: string[]): { index: number }[] + suggest(word: string): string[] + addWord(word: string): void + removeWord(word: string): void + destroy(): void +} + +const createSpellChecker = async ({ + lang, + learnedWords, + dictionariesRoot, +}: { + lang: string + learnedWords: string[] + dictionariesRoot: string +}) => { + const hunspell = await Hunspell() + + const { + cwrap, + FS, + WORKERFS, + stringToNewUTF8, + _malloc, + _free, + getValue, + UTF8ToString, + } = hunspell + + // https://github.com/hunspell/hunspell/blob/master/src/hunspell/hunspell.h + // https://github.com/kwonoj/hunspell-asm/blob/master/src/wrapHunspellInterface.ts + + const create = cwrap('Hunspell_create', 'number', ['number', 'number']) + const destroy = cwrap('Hunspell_destroy', 'number', ['number', 'number']) + const spell = cwrap('Hunspell_spell', 'number', ['number', 'number']) + const suggest = cwrap('Hunspell_suggest', 'number', [ + 'number', + 'number', + 'number', + ]) + const addWord = cwrap('Hunspell_add', 'number', ['number', 'number']) + const removeWord = cwrap('Hunspell_remove', 'number', ['number', 'number']) + const freeList = cwrap('Hunspell_free_list', 'number', [ + 'number', + 'number', + 'number', + ]) + + FS.mkdir('/dictionaries') + + const [dic, aff] = await Promise.all([ + fetch(new URL(`./${lang}.dic`, dictionariesRoot)).then(response => + response.blob() + ), + fetch(new URL(`./${lang}.aff`, dictionariesRoot)).then(response => + response.blob() + ), + ]) + + FS.mount( + WORKERFS, + { + blobs: [ + { name: 'index.dic', data: dic }, + { name: 'index.aff', data: aff }, + ], + }, + '/dictionaries' + ) + + const dicPtr = stringToNewUTF8('/dictionaries/index.dic') + const affPtr = stringToNewUTF8('/dictionaries/index.aff') + const spellPtr = create(affPtr, dicPtr) + + for (const word of learnedWords) { + const wordPtr = stringToNewUTF8(word) + addWord(spellPtr, wordPtr) + _free(wordPtr) + } + + const spellChecker: SpellChecker = { + spell(words) { + const misspellings: { index: number }[] = [] + + for (const [index, word] of words.entries()) { + const wordPtr = stringToNewUTF8(word) + const spellResult = spell(spellPtr, wordPtr) + _free(wordPtr) + + if (spellResult === 0) { + misspellings.push({ index }) + } + } + + return misspellings + }, + suggest(word) { + const suggestions: string[] = [] + + const suggestionListPtr = _malloc(4) + const wordPtr = stringToNewUTF8(word) + const suggestionCount = suggest(spellPtr, suggestionListPtr, wordPtr) + _free(wordPtr) + const suggestionListValuePtr = getValue(suggestionListPtr, '*') + + for (let i = 0; i < suggestionCount; i++) { + const suggestion = UTF8ToString( + getValue(suggestionListValuePtr + i * 4, '*') + ) + suggestions.push(suggestion) + } + + freeList(spellPtr, suggestionListPtr, suggestionCount) + _free(suggestionListPtr) + + return suggestions + }, + addWord(word) { + const wordPtr = stringToNewUTF8(word) + const result = addWord(spellPtr, wordPtr) + _free(wordPtr) + + if (result !== 0) { + throw new Error('The word could not be added to the dictionary') + } + }, + removeWord(word) { + const wordPtr = stringToNewUTF8(word) + const result = removeWord(spellPtr, wordPtr) + _free(wordPtr) + + if (result !== 0) { + throw new Error('The word could not be removed from the dictionary') + } + }, + destroy() { + destroy(spellPtr) + _free(spellPtr) + _free(dicPtr) + _free(affPtr) + }, + } + + return spellChecker +} + +let spellCheckerPromise: Promise + +self.addEventListener('message', async event => { + switch (event.data.type) { + case 'init': + try { + spellCheckerPromise = createSpellChecker(event.data) + await spellCheckerPromise + self.postMessage({ loaded: true }) + } catch (error) { + console.error(error) + self.postMessage({ error: true }) + } + break + + case 'spell': + { + const { id, words } = event.data + try { + const spellChecker = await spellCheckerPromise + const misspellings = spellChecker.spell(words) + self.postMessage({ id, misspellings }) + } catch (error) { + console.error(error) + self.postMessage({ id, error: true }) + } + } + break + + case 'suggest': + { + const { id, word } = event.data + try { + const spellChecker = await spellCheckerPromise + const suggestions = spellChecker.suggest(word) + self.postMessage({ id, suggestions }) + } catch (error) { + console.error(error) + self.postMessage({ id, error: true }) + } + } + break + + case 'add_word': + { + const { id, word } = event.data + try { + const spellChecker = await spellCheckerPromise + spellChecker.addWord(word) + self.postMessage({ id }) + } catch (error) { + console.error(error) + self.postMessage({ id, error: true }) + } + } + break + + case 'remove_word': + { + const { id, word } = event.data + try { + const spellChecker = await spellCheckerPromise + spellChecker.removeWord(word) + self.postMessage({ id }) + } catch (error) { + console.error(error) + self.postMessage({ id, error: true }) + } + } + break + + case 'destroy': + { + const { id } = event.data + try { + const spellChecker = await spellCheckerPromise + spellChecker.destroy() + self.postMessage({ id }) + } catch (error) { + console.error(error) + self.postMessage({ id, error: true }) + } + } + break + } +}) + +self.postMessage({ listening: true }) diff --git a/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts b/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts new file mode 100644 index 0000000000..6b15ba55c5 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts @@ -0,0 +1,66 @@ +/* eslint no-dupe-class-members: 0 */ + +declare class Hunspell { + cwrap( + method: 'Hunspell_create', + output: string, + input: string[] + ): (affPtr: number, dicPtr: number) => number + + cwrap( + method: 'Hunspell_destroy', + output: string, + input: string[] + ): (spellPtr: number) => number + + cwrap( + method: 'Hunspell_spell', + output: string, + input: string[] + ): (spellPtr: number, wordPtr: number) => number + + cwrap( + method: 'Hunspell_suggest', + output: string, + input: string[] + ): (spellPtr: number, suggestionListPtr: number, wordPtr: number) => number + + cwrap( + method: 'Hunspell_add', + output: string, + input: string[] + ): (spellPtr: number, wordPtr: number) => number + + cwrap( + method: 'Hunspell_remove', + output: string, + input: string[] + ): (spellPtr: number, wordPtr: number) => number + + cwrap( + method: 'Hunspell_free_list', + output: string, + input: string[] + ): (spellPtr: number, suggestionListPtr: number, n: number) => number + + stringToNewUTF8(input: string): number + UTF8ToString(input: number): string + _malloc(length: number): number + _free(ptr: number): void + getValue(ptr: number, type: string): number + FS: { + mkdir(path: string): void + mount( + type: any, + data: { blobs: Record<{ name: string; data: BlobPart }>[] }, + dir: string + ): void + } + + WORKERFS: any +} + +declare const factory = async (options?: Record) => + new Hunspell(options) + +export default factory diff --git a/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs b/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs new file mode 100644 index 0000000000..ec6cf66d7c --- /dev/null +++ b/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs @@ -0,0 +1,3940 @@ + +var Module = (() => { + var _scriptName = import.meta.url; + + return ( +function(moduleArg = {}) { + var moduleRtn; + +// include: shell.js +// The Module object: Our interface to the outside world. We import +// and export values on it. There are various ways Module can be used: +// 1. Not defined. We create it here +// 2. A function parameter, function(moduleArg) => Promise +// 3. pre-run appended it, var Module = {}; ..generated code.. +// 4. External script tag defines var Module. +// We need to check if Module already exists (e.g. case 3 above). +// Substitution will be replaced with actual code on later stage of the build, +// this way Closure Compiler will not mangle it (e.g. case 4. above). +// Note that if you want to run closure, and also to use Module +// after the generated code, you will need to define var Module = {}; +// before the code. Then that object will be used in the code, and you +// can continue to use Module afterwards as well. +var Module = moduleArg; + +// Set up the promise that indicates the Module is initialized +var readyPromiseResolve, readyPromiseReject; + +var readyPromise = new Promise((resolve, reject) => { + readyPromiseResolve = resolve; + readyPromiseReject = reject; +}); + +// Determine the runtime environment we are in. You can customize this by +// setting the ENVIRONMENT setting at compile time (see settings.js). +var ENVIRONMENT_IS_WEB = false; + +var ENVIRONMENT_IS_WORKER = true; + +// --pre-jses are emitted after the Module integration code, so that they can +// refer to Module (if they choose; they can also define Module) +// Sometimes an existing Module object exists with properties +// meant to overwrite the default module functionality. Here +// we collect those properties and reapply _after_ we configure +// the current environment's defaults to avoid having to be so +// defensive during initialization. +var moduleOverrides = Object.assign({}, Module); + +var arguments_ = []; + +var thisProgram = "./this.program"; + +// `/` should be present at the end if `scriptDirectory` is not empty +var scriptDirectory = ""; + +function locateFile(path) { + if (Module["locateFile"]) { + return Module["locateFile"](path, scriptDirectory); + } + return scriptDirectory + path; +} + +// Hooks that are implemented differently in different runtime environments. +var readAsync, readBinary; + +// Note that this includes Node.js workers when relevant (pthreads is enabled). +// Node.js workers are detected as a combination of ENVIRONMENT_IS_WORKER and +// ENVIRONMENT_IS_NODE. +if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { + if (ENVIRONMENT_IS_WORKER) { + // Check worker, not web, since window could be polyfilled + scriptDirectory = self.location.href; + } else if (typeof document != "undefined" && document.currentScript) { + // web + scriptDirectory = document.currentScript.src; + } + // When MODULARIZE, this JS may be executed later, after document.currentScript + // is gone, so we saved it, and we use it here instead of any other info. + if (_scriptName) { + scriptDirectory = _scriptName; + } + // blob urls look like blob:http://site.com/etc/etc and we cannot infer anything from them. + // otherwise, slice off the final part of the url to find the script directory. + // if scriptDirectory does not contain a slash, lastIndexOf will return -1, + // and scriptDirectory will correctly be replaced with an empty string. + // If scriptDirectory contains a query (starting with ?) or a fragment (starting with #), + // they are removed because they could contain a slash. + if (scriptDirectory.startsWith("blob:")) { + scriptDirectory = ""; + } else { + scriptDirectory = scriptDirectory.substr(0, scriptDirectory.replace(/[?#].*/, "").lastIndexOf("/") + 1); + } + { + // include: web_or_worker_shell_read.js + if (ENVIRONMENT_IS_WORKER) { + readBinary = url => { + var xhr = new XMLHttpRequest; + xhr.open("GET", url, false); + xhr.responseType = "arraybuffer"; + xhr.send(null); + return new Uint8Array(/** @type{!ArrayBuffer} */ (xhr.response)); + }; + } + readAsync = url => fetch(url, { + credentials: "same-origin" + }).then(response => { + if (response.ok) { + return response.arrayBuffer(); + } + return Promise.reject(new Error(response.status + " : " + response.url)); + }); + } +} else // end include: web_or_worker_shell_read.js +{} + +var out = Module["print"] || console.log.bind(console); + +var err = Module["printErr"] || console.error.bind(console); + +// Merge back in the overrides +Object.assign(Module, moduleOverrides); + +// Free the object hierarchy contained in the overrides, this lets the GC +// reclaim data used. +moduleOverrides = null; + +// Emit code to handle expected values on the Module object. This applies Module.x +// to the proper local x. This has two benefits: first, we only emit it if it is +// expected to arrive, and second, by using a local everywhere else that can be +// minified. +if (Module["arguments"]) arguments_ = Module["arguments"]; + +if (Module["thisProgram"]) thisProgram = Module["thisProgram"]; + +// perform assertions in shell.js after we set up out() and err(), as otherwise if an assertion fails it cannot print the message +// end include: shell.js +// include: preamble.js +// === Preamble library stuff === +// Documentation for the public APIs defined in this file must be updated in: +// site/source/docs/api_reference/preamble.js.rst +// A prebuilt local version of the documentation is available at: +// site/build/text/docs/api_reference/preamble.js.txt +// You can also build docs locally as HTML or other formats in site/ +// An online HTML version (which may be of a different version of Emscripten) +// is up at http://kripken.github.io/emscripten-site/docs/api_reference/preamble.js.html +var wasmBinary = Module["wasmBinary"]; + +// Wasm globals +var wasmMemory; + +//======================================== +// Runtime essentials +//======================================== +// whether we are quitting the application. no code should run after this. +// set in exit() and abort() +var ABORT = false; + +// In STRICT mode, we only define assert() when ASSERTIONS is set. i.e. we +// don't define it at all in release modes. This matches the behaviour of +// MINIMAL_RUNTIME. +// TODO(sbc): Make this the default even without STRICT enabled. +/** @type {function(*, string=)} */ function assert(condition, text) { + if (!condition) { + // This build was created without ASSERTIONS defined. `assert()` should not + // ever be called in this configuration but in case there are callers in + // the wild leave this simple abort() implementation here for now. + abort(text); + } +} + +// Memory management +var /** @type {!Int8Array} */ HEAP8, /** @type {!Uint8Array} */ HEAPU8, /** @type {!Int16Array} */ HEAP16, /** @type {!Uint16Array} */ HEAPU16, /** @type {!Int32Array} */ HEAP32, /** @type {!Uint32Array} */ HEAPU32, /** @type {!Float32Array} */ HEAPF32, /** @type {!Float64Array} */ HEAPF64; + +// include: runtime_shared.js +function updateMemoryViews() { + var b = wasmMemory.buffer; + Module["HEAP8"] = HEAP8 = new Int8Array(b); + Module["HEAP16"] = HEAP16 = new Int16Array(b); + Module["HEAPU8"] = HEAPU8 = new Uint8Array(b); + Module["HEAPU16"] = HEAPU16 = new Uint16Array(b); + Module["HEAP32"] = HEAP32 = new Int32Array(b); + Module["HEAPU32"] = HEAPU32 = new Uint32Array(b); + Module["HEAPF32"] = HEAPF32 = new Float32Array(b); + Module["HEAPF64"] = HEAPF64 = new Float64Array(b); +} + +// end include: runtime_shared.js +// include: runtime_stack_check.js +// end include: runtime_stack_check.js +var __ATPRERUN__ = []; + +// functions called before the runtime is initialized +var __ATINIT__ = []; + +// functions called during shutdown +var __ATPOSTRUN__ = []; + +// functions called after the main() is called +var runtimeInitialized = false; + +function preRun() { + if (Module["preRun"]) { + if (typeof Module["preRun"] == "function") Module["preRun"] = [ Module["preRun"] ]; + while (Module["preRun"].length) { + addOnPreRun(Module["preRun"].shift()); + } + } + callRuntimeCallbacks(__ATPRERUN__); +} + +function initRuntime() { + runtimeInitialized = true; + if (!Module["noFSInit"] && !FS.initialized) FS.init(); + FS.ignorePermissions = false; + TTY.init(); + callRuntimeCallbacks(__ATINIT__); +} + +function postRun() { + if (Module["postRun"]) { + if (typeof Module["postRun"] == "function") Module["postRun"] = [ Module["postRun"] ]; + while (Module["postRun"].length) { + addOnPostRun(Module["postRun"].shift()); + } + } + callRuntimeCallbacks(__ATPOSTRUN__); +} + +function addOnPreRun(cb) { + __ATPRERUN__.unshift(cb); +} + +function addOnInit(cb) { + __ATINIT__.unshift(cb); +} + +function addOnPostRun(cb) { + __ATPOSTRUN__.unshift(cb); +} + +// include: runtime_math.js +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/imul +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/fround +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32 +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc +// end include: runtime_math.js +// A counter of dependencies for calling run(). If we need to +// do asynchronous work before running, increment this and +// decrement it. Incrementing must happen in a place like +// Module.preRun (used by emcc to add file preloading). +// Note that you can add dependencies in preRun, even though +// it happens right before run - run will be postponed until +// the dependencies are met. +var runDependencies = 0; + +var runDependencyWatcher = null; + +var dependenciesFulfilled = null; + +// overridden to take different actions when all run dependencies are fulfilled +function getUniqueRunDependency(id) { + return id; +} + +function addRunDependency(id) { + runDependencies++; + Module["monitorRunDependencies"]?.(runDependencies); +} + +function removeRunDependency(id) { + runDependencies--; + Module["monitorRunDependencies"]?.(runDependencies); + if (runDependencies == 0) { + if (runDependencyWatcher !== null) { + clearInterval(runDependencyWatcher); + runDependencyWatcher = null; + } + if (dependenciesFulfilled) { + var callback = dependenciesFulfilled; + dependenciesFulfilled = null; + callback(); + } + } +} + +/** @param {string|number=} what */ function abort(what) { + Module["onAbort"]?.(what); + what = "Aborted(" + what + ")"; + // TODO(sbc): Should we remove printing and leave it up to whoever + // catches the exception? + err(what); + ABORT = true; + what += ". Build with -sASSERTIONS for more info."; + // Use a wasm runtime error, because a JS error might be seen as a foreign + // exception, which means we'd run destructors on it. We need the error to + // simply make the program stop. + // FIXME This approach does not work in Wasm EH because it currently does not assume + // all RuntimeErrors are from traps; it decides whether a RuntimeError is from + // a trap or not based on a hidden field within the object. So at the moment + // we don't have a way of throwing a wasm trap from JS. TODO Make a JS API that + // allows this in the wasm spec. + // Suppress closure compiler warning here. Closure compiler's builtin extern + // definition for WebAssembly.RuntimeError claims it takes no arguments even + // though it can. + // TODO(https://github.com/google/closure-compiler/pull/3913): Remove if/when upstream closure gets fixed. + /** @suppress {checkTypes} */ var e = new WebAssembly.RuntimeError(what); + readyPromiseReject(e); + // Throw the error whether or not MODULARIZE is set because abort is used + // in code paths apart from instantiation where an exception is expected + // to be thrown when abort is called. + throw e; +} + +// include: memoryprofiler.js +// end include: memoryprofiler.js +// include: URIUtils.js +// Prefix of data URIs emitted by SINGLE_FILE and related options. +var dataURIPrefix = "data:application/octet-stream;base64,"; + +/** + * Indicates whether filename is a base64 data URI. + * @noinline + */ var isDataURI = filename => filename.startsWith(dataURIPrefix); + +// end include: URIUtils.js +// include: runtime_exceptions.js +// end include: runtime_exceptions.js +function findWasmBinary() { + if (Module["locateFile"]) { + var f = "hunspell.wasm"; + if (!isDataURI(f)) { + return locateFile(f); + } + return f; + } + // Use bundler-friendly `new URL(..., import.meta.url)` pattern; works in browsers too. + return new URL("hunspell.wasm", import.meta.url).href; +} + +var wasmBinaryFile; + +function getBinarySync(file) { + if (file == wasmBinaryFile && wasmBinary) { + return new Uint8Array(wasmBinary); + } + if (readBinary) { + return readBinary(file); + } + throw "both async and sync fetching of the wasm failed"; +} + +function getBinaryPromise(binaryFile) { + // If we don't have the binary yet, load it asynchronously using readAsync. + if (!wasmBinary) { + // Fetch the binary using readAsync + return readAsync(binaryFile).then(response => new Uint8Array(/** @type{!ArrayBuffer} */ (response)), // Fall back to getBinarySync if readAsync fails + () => getBinarySync(binaryFile)); + } + // Otherwise, getBinarySync should be able to get it synchronously + return Promise.resolve().then(() => getBinarySync(binaryFile)); +} + +function instantiateArrayBuffer(binaryFile, imports, receiver) { + return getBinaryPromise(binaryFile).then(binary => WebAssembly.instantiate(binary, imports)).then(receiver, reason => { + err(`failed to asynchronously prepare wasm: ${reason}`); + abort(reason); + }); +} + +function instantiateAsync(binary, binaryFile, imports, callback) { + if (!binary && typeof WebAssembly.instantiateStreaming == "function" && !isDataURI(binaryFile) && typeof fetch == "function") { + return fetch(binaryFile, { + credentials: "same-origin" + }).then(response => { + // Suppress closure warning here since the upstream definition for + // instantiateStreaming only allows Promise rather than + // an actual Response. + // TODO(https://github.com/google/closure-compiler/pull/3913): Remove if/when upstream closure is fixed. + /** @suppress {checkTypes} */ var result = WebAssembly.instantiateStreaming(response, imports); + return result.then(callback, function(reason) { + // We expect the most common failure cause to be a bad MIME type for the binary, + // in which case falling back to ArrayBuffer instantiation should work. + err(`wasm streaming compile failed: ${reason}`); + err("falling back to ArrayBuffer instantiation"); + return instantiateArrayBuffer(binaryFile, imports, callback); + }); + }); + } + return instantiateArrayBuffer(binaryFile, imports, callback); +} + +function getWasmImports() { + // prepare imports + return { + "env": wasmImports, + "wasi_snapshot_preview1": wasmImports + }; +} + +// Create the wasm instance. +// Receives the wasm imports, returns the exports. +function createWasm() { + var info = getWasmImports(); + // Load the wasm module and create an instance of using native support in the JS engine. + // handle a generated wasm instance, receiving its exports and + // performing other necessary setup + /** @param {WebAssembly.Module=} module*/ function receiveInstance(instance, module) { + wasmExports = instance.exports; + wasmMemory = wasmExports["memory"]; + updateMemoryViews(); + addOnInit(wasmExports["__wasm_call_ctors"]); + removeRunDependency("wasm-instantiate"); + return wasmExports; + } + // wait for the pthread pool (if any) + addRunDependency("wasm-instantiate"); + // Prefer streaming instantiation if available. + function receiveInstantiationResult(result) { + // 'result' is a ResultObject object which has both the module and instance. + // receiveInstance() will swap in the exports (to Module.asm) so they can be called + // TODO: Due to Closure regression https://github.com/google/closure-compiler/issues/3193, the above line no longer optimizes out down to the following line. + // When the regression is fixed, can restore the above PTHREADS-enabled path. + receiveInstance(result["instance"]); + } + // User shell pages can write their own Module.instantiateWasm = function(imports, successCallback) callback + // to manually instantiate the Wasm module themselves. This allows pages to + // run the instantiation parallel to any other async startup actions they are + // performing. + // Also pthreads and wasm workers initialize the wasm instance through this + // path. + if (Module["instantiateWasm"]) { + try { + return Module["instantiateWasm"](info, receiveInstance); + } catch (e) { + err(`Module.instantiateWasm callback failed with error: ${e}`); + // If instantiation fails, reject the module ready promise. + readyPromiseReject(e); + } + } + if (!wasmBinaryFile) wasmBinaryFile = findWasmBinary(); + // If instantiation fails, reject the module ready promise. + instantiateAsync(wasmBinary, wasmBinaryFile, info, receiveInstantiationResult).catch(readyPromiseReject); + return {}; +} + +// Globals used by JS i64 conversions (see makeSetValue) +var tempDouble; + +var tempI64; + +var callRuntimeCallbacks = callbacks => { + while (callbacks.length > 0) { + // Pass the module as the first argument. + callbacks.shift()(Module); + } +}; + +/** + * @param {number} ptr + * @param {string} type + */ function getValue(ptr, type = "i8") { + if (type.endsWith("*")) type = "*"; + switch (type) { + case "i1": + return HEAP8[ptr]; + + case "i8": + return HEAP8[ptr]; + + case "i16": + return HEAP16[((ptr) >> 1)]; + + case "i32": + return HEAP32[((ptr) >> 2)]; + + case "i64": + abort("to do getValue(i64) use WASM_BIGINT"); + + case "float": + return HEAPF32[((ptr) >> 2)]; + + case "double": + return HEAPF64[((ptr) >> 3)]; + + case "*": + return HEAPU32[((ptr) >> 2)]; + + default: + abort(`invalid type for getValue: ${type}`); + } +} + +var noExitRuntime = Module["noExitRuntime"] || true; + +var stackRestore = val => __emscripten_stack_restore(val); + +var stackSave = () => _emscripten_stack_get_current(); + +var UTF8Decoder = typeof TextDecoder != "undefined" ? new TextDecoder : undefined; + +/** + * Given a pointer 'idx' to a null-terminated UTF8-encoded string in the given + * array that contains uint8 values, returns a copy of that string as a + * Javascript String object. + * heapOrArray is either a regular array, or a JavaScript typed array view. + * @param {number} idx + * @param {number=} maxBytesToRead + * @return {string} + */ var UTF8ArrayToString = (heapOrArray, idx, maxBytesToRead) => { + var endIdx = idx + maxBytesToRead; + var endPtr = idx; + // TextDecoder needs to know the byte length in advance, it doesn't stop on + // null terminator by itself. Also, use the length info to avoid running tiny + // strings through TextDecoder, since .subarray() allocates garbage. + // (As a tiny code save trick, compare endPtr against endIdx using a negation, + // so that undefined means Infinity) + while (heapOrArray[endPtr] && !(endPtr >= endIdx)) ++endPtr; + if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) { + return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr)); + } + var str = ""; + // If building with TextDecoder, we have already computed the string length + // above, so test loop end condition against that + while (idx < endPtr) { + // For UTF8 byte structure, see: + // http://en.wikipedia.org/wiki/UTF-8#Description + // https://www.ietf.org/rfc/rfc2279.txt + // https://tools.ietf.org/html/rfc3629 + var u0 = heapOrArray[idx++]; + if (!(u0 & 128)) { + str += String.fromCharCode(u0); + continue; + } + var u1 = heapOrArray[idx++] & 63; + if ((u0 & 224) == 192) { + str += String.fromCharCode(((u0 & 31) << 6) | u1); + continue; + } + var u2 = heapOrArray[idx++] & 63; + if ((u0 & 240) == 224) { + u0 = ((u0 & 15) << 12) | (u1 << 6) | u2; + } else { + u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heapOrArray[idx++] & 63); + } + if (u0 < 65536) { + str += String.fromCharCode(u0); + } else { + var ch = u0 - 65536; + str += String.fromCharCode(55296 | (ch >> 10), 56320 | (ch & 1023)); + } + } + return str; +}; + +/** + * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the + * emscripten HEAP, returns a copy of that string as a Javascript String object. + * + * @param {number} ptr + * @param {number=} maxBytesToRead - An optional length that specifies the + * maximum number of bytes to read. You can omit this parameter to scan the + * string until the first 0 byte. If maxBytesToRead is passed, and the string + * at [ptr, ptr+maxBytesToReadr[ contains a null byte in the middle, then the + * string will cut short at that byte index (i.e. maxBytesToRead will not + * produce a string of exact length [ptr, ptr+maxBytesToRead[) N.B. mixing + * frequent uses of UTF8ToString() with and without maxBytesToRead may throw + * JS JIT optimizations off, so it is worth to consider consistently using one + * @return {string} + */ var UTF8ToString = (ptr, maxBytesToRead) => ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : ""; + +var ___assert_fail = (condition, filename, line, func) => { + abort(`Assertion failed: ${UTF8ToString(condition)}, at: ` + [ filename ? UTF8ToString(filename) : "unknown filename", line, func ? UTF8ToString(func) : "unknown function" ]); +}; + +class ExceptionInfo { + // excPtr - Thrown object pointer to wrap. Metadata pointer is calculated from it. + constructor(excPtr) { + this.excPtr = excPtr; + this.ptr = excPtr - 24; + } + set_type(type) { + HEAPU32[(((this.ptr) + (4)) >> 2)] = type; + } + get_type() { + return HEAPU32[(((this.ptr) + (4)) >> 2)]; + } + set_destructor(destructor) { + HEAPU32[(((this.ptr) + (8)) >> 2)] = destructor; + } + get_destructor() { + return HEAPU32[(((this.ptr) + (8)) >> 2)]; + } + set_caught(caught) { + caught = caught ? 1 : 0; + HEAP8[(this.ptr) + (12)] = caught; + } + get_caught() { + return HEAP8[(this.ptr) + (12)] != 0; + } + set_rethrown(rethrown) { + rethrown = rethrown ? 1 : 0; + HEAP8[(this.ptr) + (13)] = rethrown; + } + get_rethrown() { + return HEAP8[(this.ptr) + (13)] != 0; + } + // Initialize native structure fields. Should be called once after allocated. + init(type, destructor) { + this.set_adjusted_ptr(0); + this.set_type(type); + this.set_destructor(destructor); + } + set_adjusted_ptr(adjustedPtr) { + HEAPU32[(((this.ptr) + (16)) >> 2)] = adjustedPtr; + } + get_adjusted_ptr() { + return HEAPU32[(((this.ptr) + (16)) >> 2)]; + } +} + +var exceptionLast = 0; + +var uncaughtExceptionCount = 0; + +var ___cxa_throw = (ptr, type, destructor) => { + var info = new ExceptionInfo(ptr); + // Initialize ExceptionInfo content after it was allocated in __cxa_allocate_exception. + info.init(type, destructor); + exceptionLast = ptr; + uncaughtExceptionCount++; + throw exceptionLast; +}; + +/** @suppress {duplicate } */ function syscallGetVarargI() { + // the `+` prepended here is necessary to convince the JSCompiler that varargs is indeed a number. + var ret = HEAP32[((+SYSCALLS.varargs) >> 2)]; + SYSCALLS.varargs += 4; + return ret; +} + +var syscallGetVarargP = syscallGetVarargI; + +var PATH = { + isAbs: path => path.charAt(0) === "/", + splitPath: filename => { + var splitPathRe = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/; + return splitPathRe.exec(filename).slice(1); + }, + normalizeArray: (parts, allowAboveRoot) => { + // if the path tries to go above the root, `up` ends up > 0 + var up = 0; + for (var i = parts.length - 1; i >= 0; i--) { + var last = parts[i]; + if (last === ".") { + parts.splice(i, 1); + } else if (last === "..") { + parts.splice(i, 1); + up++; + } else if (up) { + parts.splice(i, 1); + up--; + } + } + // if the path is allowed to go above the root, restore leading ..s + if (allowAboveRoot) { + for (;up; up--) { + parts.unshift(".."); + } + } + return parts; + }, + normalize: path => { + var isAbsolute = PATH.isAbs(path), trailingSlash = path.substr(-1) === "/"; + // Normalize the path + path = PATH.normalizeArray(path.split("/").filter(p => !!p), !isAbsolute).join("/"); + if (!path && !isAbsolute) { + path = "."; + } + if (path && trailingSlash) { + path += "/"; + } + return (isAbsolute ? "/" : "") + path; + }, + dirname: path => { + var result = PATH.splitPath(path), root = result[0], dir = result[1]; + if (!root && !dir) { + // No dirname whatsoever + return "."; + } + if (dir) { + // It has a dirname, strip trailing slash + dir = dir.substr(0, dir.length - 1); + } + return root + dir; + }, + basename: path => { + // EMSCRIPTEN return '/'' for '/', not an empty string + if (path === "/") return "/"; + path = PATH.normalize(path); + path = path.replace(/\/$/, ""); + var lastSlash = path.lastIndexOf("/"); + if (lastSlash === -1) return path; + return path.substr(lastSlash + 1); + }, + join: (...paths) => PATH.normalize(paths.join("/")), + join2: (l, r) => PATH.normalize(l + "/" + r) +}; + +var initRandomFill = () => { + if (typeof crypto == "object" && typeof crypto["getRandomValues"] == "function") { + // for modern web browsers + return view => crypto.getRandomValues(view); + } else // we couldn't find a proper implementation, as Math.random() is not suitable for /dev/random, see emscripten-core/emscripten/pull/7096 + abort("initRandomDevice"); +}; + +var randomFill = view => (randomFill = initRandomFill())(view); + +var PATH_FS = { + resolve: (...args) => { + var resolvedPath = "", resolvedAbsolute = false; + for (var i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) { + var path = (i >= 0) ? args[i] : FS.cwd(); + // Skip empty and invalid entries + if (typeof path != "string") { + throw new TypeError("Arguments to path.resolve must be strings"); + } else if (!path) { + return ""; + } + // an invalid portion invalidates the whole thing + resolvedPath = path + "/" + resolvedPath; + resolvedAbsolute = PATH.isAbs(path); + } + // At this point the path should be resolved to a full absolute path, but + // handle relative paths to be safe (might happen when process.cwd() fails) + resolvedPath = PATH.normalizeArray(resolvedPath.split("/").filter(p => !!p), !resolvedAbsolute).join("/"); + return ((resolvedAbsolute ? "/" : "") + resolvedPath) || "."; + }, + relative: (from, to) => { + from = PATH_FS.resolve(from).substr(1); + to = PATH_FS.resolve(to).substr(1); + function trim(arr) { + var start = 0; + for (;start < arr.length; start++) { + if (arr[start] !== "") break; + } + var end = arr.length - 1; + for (;end >= 0; end--) { + if (arr[end] !== "") break; + } + if (start > end) return []; + return arr.slice(start, end - start + 1); + } + var fromParts = trim(from.split("/")); + var toParts = trim(to.split("/")); + var length = Math.min(fromParts.length, toParts.length); + var samePartsLength = length; + for (var i = 0; i < length; i++) { + if (fromParts[i] !== toParts[i]) { + samePartsLength = i; + break; + } + } + var outputParts = []; + for (var i = samePartsLength; i < fromParts.length; i++) { + outputParts.push(".."); + } + outputParts = outputParts.concat(toParts.slice(samePartsLength)); + return outputParts.join("/"); + } +}; + +var FS_stdin_getChar_buffer = []; + +var lengthBytesUTF8 = str => { + var len = 0; + for (var i = 0; i < str.length; ++i) { + // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code + // unit, not a Unicode code point of the character! So decode + // UTF16->UTF32->UTF8. + // See http://unicode.org/faq/utf_bom.html#utf16-3 + var c = str.charCodeAt(i); + // possibly a lead surrogate + if (c <= 127) { + len++; + } else if (c <= 2047) { + len += 2; + } else if (c >= 55296 && c <= 57343) { + len += 4; + ++i; + } else { + len += 3; + } + } + return len; +}; + +var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => { + // Parameter maxBytesToWrite is not optional. Negative values, 0, null, + // undefined and false each don't write out any bytes. + if (!(maxBytesToWrite > 0)) return 0; + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + // -1 for string null terminator. + for (var i = 0; i < str.length; ++i) { + // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code + // unit, not a Unicode code point of the character! So decode + // UTF16->UTF32->UTF8. + // See http://unicode.org/faq/utf_bom.html#utf16-3 + // For UTF8 byte structure, see http://en.wikipedia.org/wiki/UTF-8#Description + // and https://www.ietf.org/rfc/rfc2279.txt + // and https://tools.ietf.org/html/rfc3629 + var u = str.charCodeAt(i); + // possibly a lead surrogate + if (u >= 55296 && u <= 57343) { + var u1 = str.charCodeAt(++i); + u = 65536 + ((u & 1023) << 10) | (u1 & 1023); + } + if (u <= 127) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 2047) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 192 | (u >> 6); + heap[outIdx++] = 128 | (u & 63); + } else if (u <= 65535) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 224 | (u >> 12); + heap[outIdx++] = 128 | ((u >> 6) & 63); + heap[outIdx++] = 128 | (u & 63); + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 240 | (u >> 18); + heap[outIdx++] = 128 | ((u >> 12) & 63); + heap[outIdx++] = 128 | ((u >> 6) & 63); + heap[outIdx++] = 128 | (u & 63); + } + } + // Null-terminate the pointer to the buffer. + heap[outIdx] = 0; + return outIdx - startIdx; +}; + +/** @type {function(string, boolean=, number=)} */ function intArrayFromString(stringy, dontAddNull, length) { + var len = length > 0 ? length : lengthBytesUTF8(stringy) + 1; + var u8array = new Array(len); + var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length); + if (dontAddNull) u8array.length = numBytesWritten; + return u8array; +} + +var FS_stdin_getChar = () => { + if (!FS_stdin_getChar_buffer.length) { + var result = null; + {} + if (!result) { + return null; + } + FS_stdin_getChar_buffer = intArrayFromString(result, true); + } + return FS_stdin_getChar_buffer.shift(); +}; + +var TTY = { + ttys: [], + init() {}, + // https://github.com/emscripten-core/emscripten/pull/1555 + // if (ENVIRONMENT_IS_NODE) { + // // currently, FS.init does not distinguish if process.stdin is a file or TTY + // // device, it always assumes it's a TTY device. because of this, we're forcing + // // process.stdin to UTF8 encoding to at least make stdin reading compatible + // // with text files until FS.init can be refactored. + // process.stdin.setEncoding('utf8'); + // } + shutdown() {}, + // https://github.com/emscripten-core/emscripten/pull/1555 + // if (ENVIRONMENT_IS_NODE) { + // // inolen: any idea as to why node -e 'process.stdin.read()' wouldn't exit immediately (with process.stdin being a tty)? + // // isaacs: because now it's reading from the stream, you've expressed interest in it, so that read() kicks off a _read() which creates a ReadReq operation + // // inolen: I thought read() in that case was a synchronous operation that just grabbed some amount of buffered data if it exists? + // // isaacs: it is. but it also triggers a _read() call, which calls readStart() on the handle + // // isaacs: do process.stdin.pause() and i'd think it'd probably close the pending call + // process.stdin.pause(); + // } + register(dev, ops) { + TTY.ttys[dev] = { + input: [], + output: [], + ops + }; + FS.registerDevice(dev, TTY.stream_ops); + }, + stream_ops: { + open(stream) { + var tty = TTY.ttys[stream.node.rdev]; + if (!tty) { + throw new FS.ErrnoError(43); + } + stream.tty = tty; + stream.seekable = false; + }, + close(stream) { + // flush any pending line data + stream.tty.ops.fsync(stream.tty); + }, + fsync(stream) { + stream.tty.ops.fsync(stream.tty); + }, + read(stream, buffer, offset, length, pos) { + /* ignored */ if (!stream.tty || !stream.tty.ops.get_char) { + throw new FS.ErrnoError(60); + } + var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = stream.tty.ops.get_char(stream.tty); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset + i] = result; + } + if (bytesRead) { + stream.node.timestamp = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + if (!stream.tty || !stream.tty.ops.put_char) { + throw new FS.ErrnoError(60); + } + try { + for (var i = 0; i < length; i++) { + stream.tty.ops.put_char(stream.tty, buffer[offset + i]); + } + } catch (e) { + throw new FS.ErrnoError(29); + } + if (length) { + stream.node.timestamp = Date.now(); + } + return i; + } + }, + default_tty_ops: { + get_char(tty) { + return FS_stdin_getChar(); + }, + put_char(tty, val) { + if (val === null || val === 10) { + out(UTF8ArrayToString(tty.output, 0)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + // val == 0 would cut text output off in the middle. + fsync(tty) { + if (tty.output && tty.output.length > 0) { + out(UTF8ArrayToString(tty.output, 0)); + tty.output = []; + } + }, + ioctl_tcgets(tty) { + // typical setting + return { + c_iflag: 25856, + c_oflag: 5, + c_cflag: 191, + c_lflag: 35387, + c_cc: [ 3, 28, 127, 21, 4, 0, 1, 0, 17, 19, 26, 0, 18, 15, 23, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ] + }; + }, + ioctl_tcsets(tty, optional_actions, data) { + // currently just ignore + return 0; + }, + ioctl_tiocgwinsz(tty) { + return [ 24, 80 ]; + } + }, + default_tty1_ops: { + put_char(tty, val) { + if (val === null || val === 10) { + err(UTF8ArrayToString(tty.output, 0)); + tty.output = []; + } else { + if (val != 0) tty.output.push(val); + } + }, + fsync(tty) { + if (tty.output && tty.output.length > 0) { + err(UTF8ArrayToString(tty.output, 0)); + tty.output = []; + } + } + } +}; + +var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment; + +var mmapAlloc = size => { + abort(); +}; + +var MEMFS = { + ops_table: null, + mount(mount) { + return MEMFS.createNode(null, "/", 16384 | 511, /* 0777 */ 0); + }, + createNode(parent, name, mode, dev) { + if (FS.isBlkdev(mode) || FS.isFIFO(mode)) { + // no supported + throw new FS.ErrnoError(63); + } + MEMFS.ops_table ||= { + dir: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + lookup: MEMFS.node_ops.lookup, + mknod: MEMFS.node_ops.mknod, + rename: MEMFS.node_ops.rename, + unlink: MEMFS.node_ops.unlink, + rmdir: MEMFS.node_ops.rmdir, + readdir: MEMFS.node_ops.readdir, + symlink: MEMFS.node_ops.symlink + }, + stream: { + llseek: MEMFS.stream_ops.llseek + } + }, + file: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: { + llseek: MEMFS.stream_ops.llseek, + read: MEMFS.stream_ops.read, + write: MEMFS.stream_ops.write, + allocate: MEMFS.stream_ops.allocate, + mmap: MEMFS.stream_ops.mmap, + msync: MEMFS.stream_ops.msync + } + }, + link: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr, + readlink: MEMFS.node_ops.readlink + }, + stream: {} + }, + chrdev: { + node: { + getattr: MEMFS.node_ops.getattr, + setattr: MEMFS.node_ops.setattr + }, + stream: FS.chrdev_stream_ops + } + }; + var node = FS.createNode(parent, name, mode, dev); + if (FS.isDir(node.mode)) { + node.node_ops = MEMFS.ops_table.dir.node; + node.stream_ops = MEMFS.ops_table.dir.stream; + node.contents = {}; + } else if (FS.isFile(node.mode)) { + node.node_ops = MEMFS.ops_table.file.node; + node.stream_ops = MEMFS.ops_table.file.stream; + node.usedBytes = 0; + // The actual number of bytes used in the typed array, as opposed to contents.length which gives the whole capacity. + // When the byte data of the file is populated, this will point to either a typed array, or a normal JS array. Typed arrays are preferred + // for performance, and used by default. However, typed arrays are not resizable like normal JS arrays are, so there is a small disk size + // penalty involved for appending file writes that continuously grow a file similar to std::vector capacity vs used -scheme. + node.contents = null; + } else if (FS.isLink(node.mode)) { + node.node_ops = MEMFS.ops_table.link.node; + node.stream_ops = MEMFS.ops_table.link.stream; + } else if (FS.isChrdev(node.mode)) { + node.node_ops = MEMFS.ops_table.chrdev.node; + node.stream_ops = MEMFS.ops_table.chrdev.stream; + } + node.timestamp = Date.now(); + // add the new node to the parent + if (parent) { + parent.contents[name] = node; + parent.timestamp = node.timestamp; + } + return node; + }, + getFileDataAsTypedArray(node) { + if (!node.contents) return new Uint8Array(0); + if (node.contents.subarray) return node.contents.subarray(0, node.usedBytes); + // Make sure to not return excess unused bytes. + return new Uint8Array(node.contents); + }, + expandFileStorage(node, newCapacity) { + var prevCapacity = node.contents ? node.contents.length : 0; + if (prevCapacity >= newCapacity) return; + // No need to expand, the storage was already large enough. + // Don't expand strictly to the given requested limit if it's only a very small increase, but instead geometrically grow capacity. + // For small filesizes (<1MB), perform size*2 geometric increase, but for large sizes, do a much more conservative size*1.125 increase to + // avoid overshooting the allocation cap by a very large margin. + var CAPACITY_DOUBLING_MAX = 1024 * 1024; + newCapacity = Math.max(newCapacity, (prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2 : 1.125)) >>> 0); + if (prevCapacity != 0) newCapacity = Math.max(newCapacity, 256); + // At minimum allocate 256b for each file when expanding. + var oldContents = node.contents; + node.contents = new Uint8Array(newCapacity); + // Allocate new storage. + if (node.usedBytes > 0) node.contents.set(oldContents.subarray(0, node.usedBytes), 0); + }, + // Copy old data over to the new storage. + resizeFileStorage(node, newSize) { + if (node.usedBytes == newSize) return; + if (newSize == 0) { + node.contents = null; + // Fully decommit when requesting a resize to zero. + node.usedBytes = 0; + } else { + var oldContents = node.contents; + node.contents = new Uint8Array(newSize); + // Allocate new storage. + if (oldContents) { + node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes))); + } + // Copy old data over to the new storage. + node.usedBytes = newSize; + } + }, + node_ops: { + getattr(node) { + var attr = {}; + // device numbers reuse inode numbers. + attr.dev = FS.isChrdev(node.mode) ? node.id : 1; + attr.ino = node.id; + attr.mode = node.mode; + attr.nlink = 1; + attr.uid = 0; + attr.gid = 0; + attr.rdev = node.rdev; + if (FS.isDir(node.mode)) { + attr.size = 4096; + } else if (FS.isFile(node.mode)) { + attr.size = node.usedBytes; + } else if (FS.isLink(node.mode)) { + attr.size = node.link.length; + } else { + attr.size = 0; + } + attr.atime = new Date(node.timestamp); + attr.mtime = new Date(node.timestamp); + attr.ctime = new Date(node.timestamp); + // NOTE: In our implementation, st_blocks = Math.ceil(st_size/st_blksize), + // but this is not required by the standard. + attr.blksize = 4096; + attr.blocks = Math.ceil(attr.size / attr.blksize); + return attr; + }, + setattr(node, attr) { + if (attr.mode !== undefined) { + node.mode = attr.mode; + } + if (attr.timestamp !== undefined) { + node.timestamp = attr.timestamp; + } + if (attr.size !== undefined) { + MEMFS.resizeFileStorage(node, attr.size); + } + }, + lookup(parent, name) { + throw FS.genericErrors[44]; + }, + mknod(parent, name, mode, dev) { + return MEMFS.createNode(parent, name, mode, dev); + }, + rename(old_node, new_dir, new_name) { + // if we're overwriting a directory at new_name, make sure it's empty. + if (FS.isDir(old_node.mode)) { + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) {} + if (new_node) { + for (var i in new_node.contents) { + throw new FS.ErrnoError(55); + } + } + } + // do the internal rewiring + delete old_node.parent.contents[old_node.name]; + old_node.parent.timestamp = Date.now(); + old_node.name = new_name; + new_dir.contents[new_name] = old_node; + new_dir.timestamp = old_node.parent.timestamp; + }, + unlink(parent, name) { + delete parent.contents[name]; + parent.timestamp = Date.now(); + }, + rmdir(parent, name) { + var node = FS.lookupNode(parent, name); + for (var i in node.contents) { + throw new FS.ErrnoError(55); + } + delete parent.contents[name]; + parent.timestamp = Date.now(); + }, + readdir(node) { + var entries = [ ".", ".." ]; + for (var key of Object.keys(node.contents)) { + entries.push(key); + } + return entries; + }, + symlink(parent, newname, oldpath) { + var node = MEMFS.createNode(parent, newname, 511 | /* 0777 */ 40960, 0); + node.link = oldpath; + return node; + }, + readlink(node) { + if (!FS.isLink(node.mode)) { + throw new FS.ErrnoError(28); + } + return node.link; + } + }, + stream_ops: { + read(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= stream.node.usedBytes) return 0; + var size = Math.min(stream.node.usedBytes - position, length); + if (size > 8 && contents.subarray) { + // non-trivial, and typed array + buffer.set(contents.subarray(position, position + size), offset); + } else { + for (var i = 0; i < size; i++) buffer[offset + i] = contents[position + i]; + } + return size; + }, + write(stream, buffer, offset, length, position, canOwn) { + // If the buffer is located in main memory (HEAP), and if + // memory can grow, we can't hold on to references of the + // memory buffer, as they may get invalidated. That means we + // need to do copy its contents. + if (buffer.buffer === HEAP8.buffer) { + canOwn = false; + } + if (!length) return 0; + var node = stream.node; + node.timestamp = Date.now(); + if (buffer.subarray && (!node.contents || node.contents.subarray)) { + // This write is from a typed array to a typed array? + if (canOwn) { + node.contents = buffer.subarray(offset, offset + length); + node.usedBytes = length; + return length; + } else if (node.usedBytes === 0 && position === 0) { + // If this is a simple first write to an empty file, do a fast set since we don't need to care about old data. + node.contents = buffer.slice(offset, offset + length); + node.usedBytes = length; + return length; + } else if (position + length <= node.usedBytes) { + // Writing to an already allocated and used subrange of the file? + node.contents.set(buffer.subarray(offset, offset + length), position); + return length; + } + } + // Appending to an existing file and we need to reallocate, or source data did not come as a typed array. + MEMFS.expandFileStorage(node, position + length); + if (node.contents.subarray && buffer.subarray) { + // Use typed array write which is available. + node.contents.set(buffer.subarray(offset, offset + length), position); + } else { + for (var i = 0; i < length; i++) { + node.contents[position + i] = buffer[offset + i]; + } + } + node.usedBytes = Math.max(node.usedBytes, position + length); + return length; + }, + llseek(stream, offset, whence) { + var position = offset; + if (whence === 1) { + position += stream.position; + } else if (whence === 2) { + if (FS.isFile(stream.node.mode)) { + position += stream.node.usedBytes; + } + } + if (position < 0) { + throw new FS.ErrnoError(28); + } + return position; + }, + allocate(stream, offset, length) { + MEMFS.expandFileStorage(stream.node, offset + length); + stream.node.usedBytes = Math.max(stream.node.usedBytes, offset + length); + }, + mmap(stream, length, position, prot, flags) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + var ptr; + var allocated; + var contents = stream.node.contents; + // Only make a new copy when MAP_PRIVATE is specified. + if (!(flags & 2) && contents && contents.buffer === HEAP8.buffer) { + // We can't emulate MAP_SHARED when the file is not backed by the + // buffer we're mapping to (e.g. the HEAP buffer). + allocated = false; + ptr = contents.byteOffset; + } else { + allocated = true; + ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + if (contents) { + // Try to avoid unnecessary slices. + if (position > 0 || position + length < contents.length) { + if (contents.subarray) { + contents = contents.subarray(position, position + length); + } else { + contents = Array.prototype.slice.call(contents, position, position + length); + } + } + HEAP8.set(contents, ptr); + } + } + return { + ptr, + allocated + }; + }, + msync(stream, buffer, offset, length, mmapFlags) { + MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false); + // should we check if bytesWritten and length are the same? + return 0; + } + } +}; + +/** @param {boolean=} noRunDep */ var asyncLoad = (url, onload, onerror, noRunDep) => { + var dep = !noRunDep ? getUniqueRunDependency(`al ${url}`) : ""; + readAsync(url).then(arrayBuffer => { + onload(new Uint8Array(arrayBuffer)); + if (dep) removeRunDependency(dep); + }, err => { + if (onerror) { + onerror(); + } else { + throw `Loading data file "${url}" failed.`; + } + }); + if (dep) addRunDependency(dep); +}; + +var FS_createDataFile = (parent, name, fileData, canRead, canWrite, canOwn) => { + FS.createDataFile(parent, name, fileData, canRead, canWrite, canOwn); +}; + +var preloadPlugins = Module["preloadPlugins"] || []; + +var FS_handledByPreloadPlugin = (byteArray, fullname, finish, onerror) => { + // Ensure plugins are ready. + if (typeof Browser != "undefined") Browser.init(); + var handled = false; + preloadPlugins.forEach(plugin => { + if (handled) return; + if (plugin["canHandle"](fullname)) { + plugin["handle"](byteArray, fullname, finish, onerror); + handled = true; + } + }); + return handled; +}; + +var FS_createPreloadedFile = (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) => { + // TODO we should allow people to just pass in a complete filename instead + // of parent and name being that we just join them anyways + var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent; + var dep = getUniqueRunDependency(`cp ${fullname}`); + // might have several active requests for the same fullname + function processData(byteArray) { + function finish(byteArray) { + preFinish?.(); + if (!dontCreateFile) { + FS_createDataFile(parent, name, byteArray, canRead, canWrite, canOwn); + } + onload?.(); + removeRunDependency(dep); + } + if (FS_handledByPreloadPlugin(byteArray, fullname, finish, () => { + onerror?.(); + removeRunDependency(dep); + })) { + return; + } + finish(byteArray); + } + addRunDependency(dep); + if (typeof url == "string") { + asyncLoad(url, processData, onerror); + } else { + processData(url); + } +}; + +var FS_modeStringToFlags = str => { + var flagModes = { + "r": 0, + "r+": 2, + "w": 512 | 64 | 1, + "w+": 512 | 64 | 2, + "a": 1024 | 64 | 1, + "a+": 1024 | 64 | 2 + }; + var flags = flagModes[str]; + if (typeof flags == "undefined") { + throw new Error(`Unknown file open mode: ${str}`); + } + return flags; +}; + +var FS_getMode = (canRead, canWrite) => { + var mode = 0; + if (canRead) mode |= 292 | 73; + if (canWrite) mode |= 146; + return mode; +}; + +var WORKERFS = { + DIR_MODE: 16895, + FILE_MODE: 33279, + reader: null, + mount(mount) { + assert(ENVIRONMENT_IS_WORKER); + if (!WORKERFS.reader) WORKERFS.reader = new FileReaderSync; + var root = WORKERFS.createNode(null, "/", WORKERFS.DIR_MODE, 0); + var createdParents = {}; + function ensureParent(path) { + // return the parent node, creating subdirs as necessary + var parts = path.split("/"); + var parent = root; + for (var i = 0; i < parts.length - 1; i++) { + var curr = parts.slice(0, i + 1).join("/"); + // Issue 4254: Using curr as a node name will prevent the node + // from being found in FS.nameTable when FS.open is called on + // a path which holds a child of this node, + // given that all FS functions assume node names + // are just their corresponding parts within their given path, + // rather than incremental aggregates which include their parent's + // directories. + createdParents[curr] ||= WORKERFS.createNode(parent, parts[i], WORKERFS.DIR_MODE, 0); + parent = createdParents[curr]; + } + return parent; + } + function base(path) { + var parts = path.split("/"); + return parts[parts.length - 1]; + } + // We also accept FileList here, by using Array.prototype + Array.prototype.forEach.call(mount.opts["files"] || [], function(file) { + WORKERFS.createNode(ensureParent(file.name), base(file.name), WORKERFS.FILE_MODE, 0, file, file.lastModifiedDate); + }); + (mount.opts["blobs"] || []).forEach(obj => { + WORKERFS.createNode(ensureParent(obj["name"]), base(obj["name"]), WORKERFS.FILE_MODE, 0, obj["data"]); + }); + (mount.opts["packages"] || []).forEach(pack => { + pack["metadata"].files.forEach(file => { + var name = file.filename.substr(1); + // remove initial slash + WORKERFS.createNode(ensureParent(name), base(name), WORKERFS.FILE_MODE, 0, pack["blob"].slice(file.start, file.end)); + }); + }); + return root; + }, + createNode(parent, name, mode, dev, contents, mtime) { + var node = FS.createNode(parent, name, mode); + node.mode = mode; + node.node_ops = WORKERFS.node_ops; + node.stream_ops = WORKERFS.stream_ops; + node.timestamp = (mtime || new Date).getTime(); + assert(WORKERFS.FILE_MODE !== WORKERFS.DIR_MODE); + if (mode === WORKERFS.FILE_MODE) { + node.size = contents.size; + node.contents = contents; + } else { + node.size = 4096; + node.contents = {}; + } + if (parent) { + parent.contents[name] = node; + } + return node; + }, + node_ops: { + getattr(node) { + return { + dev: 1, + ino: node.id, + mode: node.mode, + nlink: 1, + uid: 0, + gid: 0, + rdev: 0, + size: node.size, + atime: new Date(node.timestamp), + mtime: new Date(node.timestamp), + ctime: new Date(node.timestamp), + blksize: 4096, + blocks: Math.ceil(node.size / 4096) + }; + }, + setattr(node, attr) { + if (attr.mode !== undefined) { + node.mode = attr.mode; + } + if (attr.timestamp !== undefined) { + node.timestamp = attr.timestamp; + } + }, + lookup(parent, name) { + throw new FS.ErrnoError(44); + }, + mknod(parent, name, mode, dev) { + throw new FS.ErrnoError(63); + }, + rename(oldNode, newDir, newName) { + throw new FS.ErrnoError(63); + }, + unlink(parent, name) { + throw new FS.ErrnoError(63); + }, + rmdir(parent, name) { + throw new FS.ErrnoError(63); + }, + readdir(node) { + var entries = [ ".", ".." ]; + for (var key of Object.keys(node.contents)) { + entries.push(key); + } + return entries; + }, + symlink(parent, newName, oldPath) { + throw new FS.ErrnoError(63); + } + }, + stream_ops: { + read(stream, buffer, offset, length, position) { + if (position >= stream.node.size) return 0; + var chunk = stream.node.contents.slice(position, position + length); + var ab = WORKERFS.reader.readAsArrayBuffer(chunk); + buffer.set(new Uint8Array(ab), offset); + return chunk.size; + }, + write(stream, buffer, offset, length, position) { + throw new FS.ErrnoError(29); + }, + llseek(stream, offset, whence) { + var position = offset; + if (whence === 1) { + position += stream.position; + } else if (whence === 2) { + if (FS.isFile(stream.node.mode)) { + position += stream.node.size; + } + } + if (position < 0) { + throw new FS.ErrnoError(28); + } + return position; + } + } +}; + +var FS = { + root: null, + mounts: [], + devices: {}, + streams: [], + nextInode: 1, + nameTable: null, + currentPath: "/", + initialized: false, + ignorePermissions: true, + ErrnoError: class { + // We set the `name` property to be able to identify `FS.ErrnoError` + // - the `name` is a standard ECMA-262 property of error objects. Kind of good to have it anyway. + // - when using PROXYFS, an error can come from an underlying FS + // as different FS objects have their own FS.ErrnoError each, + // the test `err instanceof FS.ErrnoError` won't detect an error coming from another filesystem, causing bugs. + // we'll use the reliable test `err.name == "ErrnoError"` instead + constructor(errno) { + // TODO(sbc): Use the inline member declaration syntax once we + // support it in acorn and closure. + this.name = "ErrnoError"; + this.errno = errno; + } + }, + genericErrors: {}, + filesystems: null, + syncFSRequests: 0, + FSStream: class { + constructor() { + // TODO(https://github.com/emscripten-core/emscripten/issues/21414): + // Use inline field declarations. + this.shared = {}; + } + get object() { + return this.node; + } + set object(val) { + this.node = val; + } + get isRead() { + return (this.flags & 2097155) !== 1; + } + get isWrite() { + return (this.flags & 2097155) !== 0; + } + get isAppend() { + return (this.flags & 1024); + } + get flags() { + return this.shared.flags; + } + set flags(val) { + this.shared.flags = val; + } + get position() { + return this.shared.position; + } + set position(val) { + this.shared.position = val; + } + }, + FSNode: class { + constructor(parent, name, mode, rdev) { + if (!parent) { + parent = this; + } + // root node sets parent to itself + this.parent = parent; + this.mount = parent.mount; + this.mounted = null; + this.id = FS.nextInode++; + this.name = name; + this.mode = mode; + this.node_ops = {}; + this.stream_ops = {}; + this.rdev = rdev; + this.readMode = 292 | 73; + this.writeMode = 146; + } + get read() { + return (this.mode & this.readMode) === this.readMode; + } + set read(val) { + val ? this.mode |= this.readMode : this.mode &= ~this.readMode; + } + get write() { + return (this.mode & this.writeMode) === this.writeMode; + } + set write(val) { + val ? this.mode |= this.writeMode : this.mode &= ~this.writeMode; + } + get isFolder() { + return FS.isDir(this.mode); + } + get isDevice() { + return FS.isChrdev(this.mode); + } + }, + lookupPath(path, opts = {}) { + path = PATH_FS.resolve(path); + if (!path) return { + path: "", + node: null + }; + var defaults = { + follow_mount: true, + recurse_count: 0 + }; + opts = Object.assign(defaults, opts); + if (opts.recurse_count > 8) { + // max recursive lookup of 8 + throw new FS.ErrnoError(32); + } + // split the absolute path + var parts = path.split("/").filter(p => !!p); + // start at the root + var current = FS.root; + var current_path = "/"; + for (var i = 0; i < parts.length; i++) { + var islast = (i === parts.length - 1); + if (islast && opts.parent) { + // stop resolving + break; + } + current = FS.lookupNode(current, parts[i]); + current_path = PATH.join2(current_path, parts[i]); + // jump to the mount's root node if this is a mountpoint + if (FS.isMountpoint(current)) { + if (!islast || (islast && opts.follow_mount)) { + current = current.mounted.root; + } + } + // by default, lookupPath will not follow a symlink if it is the final path component. + // setting opts.follow = true will override this behavior. + if (!islast || opts.follow) { + var count = 0; + while (FS.isLink(current.mode)) { + var link = FS.readlink(current_path); + current_path = PATH_FS.resolve(PATH.dirname(current_path), link); + var lookup = FS.lookupPath(current_path, { + recurse_count: opts.recurse_count + 1 + }); + current = lookup.node; + if (count++ > 40) { + // limit max consecutive symlinks to 40 (SYMLOOP_MAX). + throw new FS.ErrnoError(32); + } + } + } + } + return { + path: current_path, + node: current + }; + }, + getPath(node) { + var path; + while (true) { + if (FS.isRoot(node)) { + var mount = node.mount.mountpoint; + if (!path) return mount; + return mount[mount.length - 1] !== "/" ? `${mount}/${path}` : mount + path; + } + path = path ? `${node.name}/${path}` : node.name; + node = node.parent; + } + }, + hashName(parentid, name) { + var hash = 0; + for (var i = 0; i < name.length; i++) { + hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0; + } + return ((parentid + hash) >>> 0) % FS.nameTable.length; + }, + hashAddNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + node.name_next = FS.nameTable[hash]; + FS.nameTable[hash] = node; + }, + hashRemoveNode(node) { + var hash = FS.hashName(node.parent.id, node.name); + if (FS.nameTable[hash] === node) { + FS.nameTable[hash] = node.name_next; + } else { + var current = FS.nameTable[hash]; + while (current) { + if (current.name_next === node) { + current.name_next = node.name_next; + break; + } + current = current.name_next; + } + } + }, + lookupNode(parent, name) { + var errCode = FS.mayLookup(parent); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + var hash = FS.hashName(parent.id, name); + for (var node = FS.nameTable[hash]; node; node = node.name_next) { + var nodeName = node.name; + if (node.parent.id === parent.id && nodeName === name) { + return node; + } + } + // if we failed to find it in the cache, call into the VFS + return FS.lookup(parent, name); + }, + createNode(parent, name, mode, rdev) { + var node = new FS.FSNode(parent, name, mode, rdev); + FS.hashAddNode(node); + return node; + }, + destroyNode(node) { + FS.hashRemoveNode(node); + }, + isRoot(node) { + return node === node.parent; + }, + isMountpoint(node) { + return !!node.mounted; + }, + isFile(mode) { + return (mode & 61440) === 32768; + }, + isDir(mode) { + return (mode & 61440) === 16384; + }, + isLink(mode) { + return (mode & 61440) === 40960; + }, + isChrdev(mode) { + return (mode & 61440) === 8192; + }, + isBlkdev(mode) { + return (mode & 61440) === 24576; + }, + isFIFO(mode) { + return (mode & 61440) === 4096; + }, + isSocket(mode) { + return (mode & 49152) === 49152; + }, + flagsToPermissionString(flag) { + var perms = [ "r", "w", "rw" ][flag & 3]; + if ((flag & 512)) { + perms += "w"; + } + return perms; + }, + nodePermissions(node, perms) { + if (FS.ignorePermissions) { + return 0; + } + // return 0 if any user, group or owner bits are set. + if (perms.includes("r") && !(node.mode & 292)) { + return 2; + } else if (perms.includes("w") && !(node.mode & 146)) { + return 2; + } else if (perms.includes("x") && !(node.mode & 73)) { + return 2; + } + return 0; + }, + mayLookup(dir) { + if (!FS.isDir(dir.mode)) return 54; + var errCode = FS.nodePermissions(dir, "x"); + if (errCode) return errCode; + if (!dir.node_ops.lookup) return 2; + return 0; + }, + mayCreate(dir, name) { + try { + var node = FS.lookupNode(dir, name); + return 20; + } catch (e) {} + return FS.nodePermissions(dir, "wx"); + }, + mayDelete(dir, name, isdir) { + var node; + try { + node = FS.lookupNode(dir, name); + } catch (e) { + return e.errno; + } + var errCode = FS.nodePermissions(dir, "wx"); + if (errCode) { + return errCode; + } + if (isdir) { + if (!FS.isDir(node.mode)) { + return 54; + } + if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) { + return 10; + } + } else { + if (FS.isDir(node.mode)) { + return 31; + } + } + return 0; + }, + mayOpen(node, flags) { + if (!node) { + return 44; + } + if (FS.isLink(node.mode)) { + return 32; + } else if (FS.isDir(node.mode)) { + if (FS.flagsToPermissionString(flags) !== "r" || // opening for write + (flags & 512)) { + // TODO: check for O_SEARCH? (== search for dir only) + return 31; + } + } + return FS.nodePermissions(node, FS.flagsToPermissionString(flags)); + }, + MAX_OPEN_FDS: 4096, + nextfd() { + for (var fd = 0; fd <= FS.MAX_OPEN_FDS; fd++) { + if (!FS.streams[fd]) { + return fd; + } + } + throw new FS.ErrnoError(33); + }, + getStreamChecked(fd) { + var stream = FS.getStream(fd); + if (!stream) { + throw new FS.ErrnoError(8); + } + return stream; + }, + getStream: fd => FS.streams[fd], + createStream(stream, fd = -1) { + // clone it, so we can return an instance of FSStream + stream = Object.assign(new FS.FSStream, stream); + if (fd == -1) { + fd = FS.nextfd(); + } + stream.fd = fd; + FS.streams[fd] = stream; + return stream; + }, + closeStream(fd) { + FS.streams[fd] = null; + }, + dupStream(origStream, fd = -1) { + var stream = FS.createStream(origStream, fd); + stream.stream_ops?.dup?.(stream); + return stream; + }, + chrdev_stream_ops: { + open(stream) { + var device = FS.getDevice(stream.node.rdev); + // override node's stream ops with the device's + stream.stream_ops = device.stream_ops; + // forward the open call + stream.stream_ops.open?.(stream); + }, + llseek() { + throw new FS.ErrnoError(70); + } + }, + major: dev => ((dev) >> 8), + minor: dev => ((dev) & 255), + makedev: (ma, mi) => ((ma) << 8 | (mi)), + registerDevice(dev, ops) { + FS.devices[dev] = { + stream_ops: ops + }; + }, + getDevice: dev => FS.devices[dev], + getMounts(mount) { + var mounts = []; + var check = [ mount ]; + while (check.length) { + var m = check.pop(); + mounts.push(m); + check.push(...m.mounts); + } + return mounts; + }, + syncfs(populate, callback) { + if (typeof populate == "function") { + callback = populate; + populate = false; + } + FS.syncFSRequests++; + if (FS.syncFSRequests > 1) { + err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`); + } + var mounts = FS.getMounts(FS.root.mount); + var completed = 0; + function doCallback(errCode) { + FS.syncFSRequests--; + return callback(errCode); + } + function done(errCode) { + if (errCode) { + if (!done.errored) { + done.errored = true; + return doCallback(errCode); + } + return; + } + if (++completed >= mounts.length) { + doCallback(null); + } + } + // sync all mounts + mounts.forEach(mount => { + if (!mount.type.syncfs) { + return done(null); + } + mount.type.syncfs(mount, populate, done); + }); + }, + mount(type, opts, mountpoint) { + var root = mountpoint === "/"; + var pseudo = !mountpoint; + var node; + if (root && FS.root) { + throw new FS.ErrnoError(10); + } else if (!root && !pseudo) { + var lookup = FS.lookupPath(mountpoint, { + follow_mount: false + }); + mountpoint = lookup.path; + // use the absolute path + node = lookup.node; + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + if (!FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + } + var mount = { + type, + opts, + mountpoint, + mounts: [] + }; + // create a root node for the fs + var mountRoot = type.mount(mount); + mountRoot.mount = mount; + mount.root = mountRoot; + if (root) { + FS.root = mountRoot; + } else if (node) { + // set as a mountpoint + node.mounted = mount; + // add the new mount to the current mount's children + if (node.mount) { + node.mount.mounts.push(mount); + } + } + return mountRoot; + }, + unmount(mountpoint) { + var lookup = FS.lookupPath(mountpoint, { + follow_mount: false + }); + if (!FS.isMountpoint(lookup.node)) { + throw new FS.ErrnoError(28); + } + // destroy the nodes for this mount, and all its child mounts + var node = lookup.node; + var mount = node.mounted; + var mounts = FS.getMounts(mount); + Object.keys(FS.nameTable).forEach(hash => { + var current = FS.nameTable[hash]; + while (current) { + var next = current.name_next; + if (mounts.includes(current.mount)) { + FS.destroyNode(current); + } + current = next; + } + }); + // no longer a mountpoint + node.mounted = null; + // remove this mount from the child mounts + var idx = node.mount.mounts.indexOf(mount); + node.mount.mounts.splice(idx, 1); + }, + lookup(parent, name) { + return parent.node_ops.lookup(parent, name); + }, + mknod(path, mode, dev) { + var lookup = FS.lookupPath(path, { + parent: true + }); + var parent = lookup.node; + var name = PATH.basename(path); + if (!name || name === "." || name === "..") { + throw new FS.ErrnoError(28); + } + var errCode = FS.mayCreate(parent, name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.mknod) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.mknod(parent, name, mode, dev); + }, + create(path, mode) { + mode = mode !== undefined ? mode : 438; + /* 0666 */ mode &= 4095; + mode |= 32768; + return FS.mknod(path, mode, 0); + }, + mkdir(path, mode) { + mode = mode !== undefined ? mode : 511; + /* 0777 */ mode &= 511 | 512; + mode |= 16384; + return FS.mknod(path, mode, 0); + }, + mkdirTree(path, mode) { + var dirs = path.split("/"); + var d = ""; + for (var i = 0; i < dirs.length; ++i) { + if (!dirs[i]) continue; + d += "/" + dirs[i]; + try { + FS.mkdir(d, mode); + } catch (e) { + if (e.errno != 20) throw e; + } + } + }, + mkdev(path, mode, dev) { + if (typeof dev == "undefined") { + dev = mode; + mode = 438; + } + /* 0666 */ mode |= 8192; + return FS.mknod(path, mode, dev); + }, + symlink(oldpath, newpath) { + if (!PATH_FS.resolve(oldpath)) { + throw new FS.ErrnoError(44); + } + var lookup = FS.lookupPath(newpath, { + parent: true + }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var newname = PATH.basename(newpath); + var errCode = FS.mayCreate(parent, newname); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.symlink) { + throw new FS.ErrnoError(63); + } + return parent.node_ops.symlink(parent, newname, oldpath); + }, + rename(old_path, new_path) { + var old_dirname = PATH.dirname(old_path); + var new_dirname = PATH.dirname(new_path); + var old_name = PATH.basename(old_path); + var new_name = PATH.basename(new_path); + // parents must exist + var lookup, old_dir, new_dir; + // let the errors from non existent directories percolate up + lookup = FS.lookupPath(old_path, { + parent: true + }); + old_dir = lookup.node; + lookup = FS.lookupPath(new_path, { + parent: true + }); + new_dir = lookup.node; + if (!old_dir || !new_dir) throw new FS.ErrnoError(44); + // need to be part of the same mount + if (old_dir.mount !== new_dir.mount) { + throw new FS.ErrnoError(75); + } + // source must exist + var old_node = FS.lookupNode(old_dir, old_name); + // old path should not be an ancestor of the new path + var relative = PATH_FS.relative(old_path, new_dirname); + if (relative.charAt(0) !== ".") { + throw new FS.ErrnoError(28); + } + // new path should not be an ancestor of the old path + relative = PATH_FS.relative(new_path, old_dirname); + if (relative.charAt(0) !== ".") { + throw new FS.ErrnoError(55); + } + // see if the new path already exists + var new_node; + try { + new_node = FS.lookupNode(new_dir, new_name); + } catch (e) {} + // early out if nothing needs to change + if (old_node === new_node) { + return; + } + // we'll need to delete the old entry + var isdir = FS.isDir(old_node.mode); + var errCode = FS.mayDelete(old_dir, old_name, isdir); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + // need delete permissions if we'll be overwriting. + // need create permissions if new doesn't already exist. + errCode = new_node ? FS.mayDelete(new_dir, new_name, isdir) : FS.mayCreate(new_dir, new_name); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!old_dir.node_ops.rename) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(old_node) || (new_node && FS.isMountpoint(new_node))) { + throw new FS.ErrnoError(10); + } + // if we are going to change the parent, check write permissions + if (new_dir !== old_dir) { + errCode = FS.nodePermissions(old_dir, "w"); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + // remove the node from the lookup hash + FS.hashRemoveNode(old_node); + // do the underlying fs rename + try { + old_dir.node_ops.rename(old_node, new_dir, new_name); + // update old node (we do this here to avoid each backend + // needing to) + old_node.parent = new_dir; + } catch (e) { + throw e; + } finally { + // add the node back to the hash (in case node_ops.rename + // changed its name) + FS.hashAddNode(old_node); + } + }, + rmdir(path) { + var lookup = FS.lookupPath(path, { + parent: true + }); + var parent = lookup.node; + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, true); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.rmdir) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.rmdir(parent, name); + FS.destroyNode(node); + }, + readdir(path) { + var lookup = FS.lookupPath(path, { + follow: true + }); + var node = lookup.node; + if (!node.node_ops.readdir) { + throw new FS.ErrnoError(54); + } + return node.node_ops.readdir(node); + }, + unlink(path) { + var lookup = FS.lookupPath(path, { + parent: true + }); + var parent = lookup.node; + if (!parent) { + throw new FS.ErrnoError(44); + } + var name = PATH.basename(path); + var node = FS.lookupNode(parent, name); + var errCode = FS.mayDelete(parent, name, false); + if (errCode) { + // According to POSIX, we should map EISDIR to EPERM, but + // we instead do what Linux does (and we must, as we use + // the musl linux libc). + throw new FS.ErrnoError(errCode); + } + if (!parent.node_ops.unlink) { + throw new FS.ErrnoError(63); + } + if (FS.isMountpoint(node)) { + throw new FS.ErrnoError(10); + } + parent.node_ops.unlink(parent, name); + FS.destroyNode(node); + }, + readlink(path) { + var lookup = FS.lookupPath(path); + var link = lookup.node; + if (!link) { + throw new FS.ErrnoError(44); + } + if (!link.node_ops.readlink) { + throw new FS.ErrnoError(28); + } + return PATH_FS.resolve(FS.getPath(link.parent), link.node_ops.readlink(link)); + }, + stat(path, dontFollow) { + var lookup = FS.lookupPath(path, { + follow: !dontFollow + }); + var node = lookup.node; + if (!node) { + throw new FS.ErrnoError(44); + } + if (!node.node_ops.getattr) { + throw new FS.ErrnoError(63); + } + return node.node_ops.getattr(node); + }, + lstat(path) { + return FS.stat(path, true); + }, + chmod(path, mode, dontFollow) { + var node; + if (typeof path == "string") { + var lookup = FS.lookupPath(path, { + follow: !dontFollow + }); + node = lookup.node; + } else { + node = path; + } + if (!node.node_ops.setattr) { + throw new FS.ErrnoError(63); + } + node.node_ops.setattr(node, { + mode: (mode & 4095) | (node.mode & ~4095), + timestamp: Date.now() + }); + }, + lchmod(path, mode) { + FS.chmod(path, mode, true); + }, + fchmod(fd, mode) { + var stream = FS.getStreamChecked(fd); + FS.chmod(stream.node, mode); + }, + chown(path, uid, gid, dontFollow) { + var node; + if (typeof path == "string") { + var lookup = FS.lookupPath(path, { + follow: !dontFollow + }); + node = lookup.node; + } else { + node = path; + } + if (!node.node_ops.setattr) { + throw new FS.ErrnoError(63); + } + node.node_ops.setattr(node, { + timestamp: Date.now() + }); + }, + // we ignore the uid / gid for now + lchown(path, uid, gid) { + FS.chown(path, uid, gid, true); + }, + fchown(fd, uid, gid) { + var stream = FS.getStreamChecked(fd); + FS.chown(stream.node, uid, gid); + }, + truncate(path, len) { + if (len < 0) { + throw new FS.ErrnoError(28); + } + var node; + if (typeof path == "string") { + var lookup = FS.lookupPath(path, { + follow: true + }); + node = lookup.node; + } else { + node = path; + } + if (!node.node_ops.setattr) { + throw new FS.ErrnoError(63); + } + if (FS.isDir(node.mode)) { + throw new FS.ErrnoError(31); + } + if (!FS.isFile(node.mode)) { + throw new FS.ErrnoError(28); + } + var errCode = FS.nodePermissions(node, "w"); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + node.node_ops.setattr(node, { + size: len, + timestamp: Date.now() + }); + }, + ftruncate(fd, len) { + var stream = FS.getStreamChecked(fd); + if ((stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(28); + } + FS.truncate(stream.node, len); + }, + utime(path, atime, mtime) { + var lookup = FS.lookupPath(path, { + follow: true + }); + var node = lookup.node; + node.node_ops.setattr(node, { + timestamp: Math.max(atime, mtime) + }); + }, + open(path, flags, mode) { + if (path === "") { + throw new FS.ErrnoError(44); + } + flags = typeof flags == "string" ? FS_modeStringToFlags(flags) : flags; + if ((flags & 64)) { + mode = typeof mode == "undefined" ? 438 : /* 0666 */ mode; + mode = (mode & 4095) | 32768; + } else { + mode = 0; + } + var node; + if (typeof path == "object") { + node = path; + } else { + path = PATH.normalize(path); + try { + var lookup = FS.lookupPath(path, { + follow: !(flags & 131072) + }); + node = lookup.node; + } catch (e) {} + } + // perhaps we need to create the node + var created = false; + if ((flags & 64)) { + if (node) { + // if O_CREAT and O_EXCL are set, error out if the node already exists + if ((flags & 128)) { + throw new FS.ErrnoError(20); + } + } else { + // node doesn't exist, try to create it + node = FS.mknod(path, mode, 0); + created = true; + } + } + if (!node) { + throw new FS.ErrnoError(44); + } + // can't truncate a device + if (FS.isChrdev(node.mode)) { + flags &= ~512; + } + // if asked only for a directory, then this must be one + if ((flags & 65536) && !FS.isDir(node.mode)) { + throw new FS.ErrnoError(54); + } + // check permissions, if this is not a file we just created now (it is ok to + // create and write to a file with read-only permissions; it is read-only + // for later use) + if (!created) { + var errCode = FS.mayOpen(node, flags); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + } + // do truncation if necessary + if ((flags & 512) && !created) { + FS.truncate(node, 0); + } + // we've already handled these, don't pass down to the underlying vfs + flags &= ~(128 | 512 | 131072); + // register the stream with the filesystem + var stream = FS.createStream({ + node, + path: FS.getPath(node), + // we want the absolute path to the node + flags, + seekable: true, + position: 0, + stream_ops: node.stream_ops, + // used by the file family libc calls (fopen, fwrite, ferror, etc.) + ungotten: [], + error: false + }); + // call the new stream's open function + if (stream.stream_ops.open) { + stream.stream_ops.open(stream); + } + if (Module["logReadFiles"] && !(flags & 1)) { + if (!FS.readFiles) FS.readFiles = {}; + if (!(path in FS.readFiles)) { + FS.readFiles[path] = 1; + } + } + return stream; + }, + close(stream) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (stream.getdents) stream.getdents = null; + // free readdir state + try { + if (stream.stream_ops.close) { + stream.stream_ops.close(stream); + } + } catch (e) { + throw e; + } finally { + FS.closeStream(stream.fd); + } + stream.fd = null; + }, + isClosed(stream) { + return stream.fd === null; + }, + llseek(stream, offset, whence) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (!stream.seekable || !stream.stream_ops.llseek) { + throw new FS.ErrnoError(70); + } + if (whence != 0 && whence != 1 && whence != 2) { + throw new FS.ErrnoError(28); + } + stream.position = stream.stream_ops.llseek(stream, offset, whence); + stream.ungotten = []; + return stream.position; + }, + read(stream, buffer, offset, length, position) { + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.read) { + throw new FS.ErrnoError(28); + } + var seeking = typeof position != "undefined"; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position); + if (!seeking) stream.position += bytesRead; + return bytesRead; + }, + write(stream, buffer, offset, length, position, canOwn) { + if (length < 0 || position < 0) { + throw new FS.ErrnoError(28); + } + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if ((stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(8); + } + if (FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(31); + } + if (!stream.stream_ops.write) { + throw new FS.ErrnoError(28); + } + if (stream.seekable && stream.flags & 1024) { + // seek to the end before writing in append mode + FS.llseek(stream, 0, 2); + } + var seeking = typeof position != "undefined"; + if (!seeking) { + position = stream.position; + } else if (!stream.seekable) { + throw new FS.ErrnoError(70); + } + var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn); + if (!seeking) stream.position += bytesWritten; + return bytesWritten; + }, + allocate(stream, offset, length) { + if (FS.isClosed(stream)) { + throw new FS.ErrnoError(8); + } + if (offset < 0 || length <= 0) { + throw new FS.ErrnoError(28); + } + if ((stream.flags & 2097155) === 0) { + throw new FS.ErrnoError(8); + } + if (!FS.isFile(stream.node.mode) && !FS.isDir(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + if (!stream.stream_ops.allocate) { + throw new FS.ErrnoError(138); + } + stream.stream_ops.allocate(stream, offset, length); + }, + mmap(stream, length, position, prot, flags) { + // User requests writing to file (prot & PROT_WRITE != 0). + // Checking if we have permissions to write to the file unless + // MAP_PRIVATE flag is set. According to POSIX spec it is possible + // to write to file opened in read-only mode with MAP_PRIVATE flag, + // as all modifications will be visible only in the memory of + // the current process. + if ((prot & 2) !== 0 && (flags & 2) === 0 && (stream.flags & 2097155) !== 2) { + throw new FS.ErrnoError(2); + } + if ((stream.flags & 2097155) === 1) { + throw new FS.ErrnoError(2); + } + if (!stream.stream_ops.mmap) { + throw new FS.ErrnoError(43); + } + if (!length) { + throw new FS.ErrnoError(28); + } + return stream.stream_ops.mmap(stream, length, position, prot, flags); + }, + msync(stream, buffer, offset, length, mmapFlags) { + if (!stream.stream_ops.msync) { + return 0; + } + return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags); + }, + ioctl(stream, cmd, arg) { + if (!stream.stream_ops.ioctl) { + throw new FS.ErrnoError(59); + } + return stream.stream_ops.ioctl(stream, cmd, arg); + }, + readFile(path, opts = {}) { + opts.flags = opts.flags || 0; + opts.encoding = opts.encoding || "binary"; + if (opts.encoding !== "utf8" && opts.encoding !== "binary") { + throw new Error(`Invalid encoding type "${opts.encoding}"`); + } + var ret; + var stream = FS.open(path, opts.flags); + var stat = FS.stat(path); + var length = stat.size; + var buf = new Uint8Array(length); + FS.read(stream, buf, 0, length, 0); + if (opts.encoding === "utf8") { + ret = UTF8ArrayToString(buf, 0); + } else if (opts.encoding === "binary") { + ret = buf; + } + FS.close(stream); + return ret; + }, + writeFile(path, data, opts = {}) { + opts.flags = opts.flags || 577; + var stream = FS.open(path, opts.flags, opts.mode); + if (typeof data == "string") { + var buf = new Uint8Array(lengthBytesUTF8(data) + 1); + var actualNumBytes = stringToUTF8Array(data, buf, 0, buf.length); + FS.write(stream, buf, 0, actualNumBytes, undefined, opts.canOwn); + } else if (ArrayBuffer.isView(data)) { + FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn); + } else { + throw new Error("Unsupported data type"); + } + FS.close(stream); + }, + cwd: () => FS.currentPath, + chdir(path) { + var lookup = FS.lookupPath(path, { + follow: true + }); + if (lookup.node === null) { + throw new FS.ErrnoError(44); + } + if (!FS.isDir(lookup.node.mode)) { + throw new FS.ErrnoError(54); + } + var errCode = FS.nodePermissions(lookup.node, "x"); + if (errCode) { + throw new FS.ErrnoError(errCode); + } + FS.currentPath = lookup.path; + }, + createDefaultDirectories() { + FS.mkdir("/tmp"); + FS.mkdir("/home"); + FS.mkdir("/home/web_user"); + }, + createDefaultDevices() { + // create /dev + FS.mkdir("/dev"); + // setup /dev/null + FS.registerDevice(FS.makedev(1, 3), { + read: () => 0, + write: (stream, buffer, offset, length, pos) => length + }); + FS.mkdev("/dev/null", FS.makedev(1, 3)); + // setup /dev/tty and /dev/tty1 + // stderr needs to print output using err() rather than out() + // so we register a second tty just for it. + TTY.register(FS.makedev(5, 0), TTY.default_tty_ops); + TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops); + FS.mkdev("/dev/tty", FS.makedev(5, 0)); + FS.mkdev("/dev/tty1", FS.makedev(6, 0)); + // setup /dev/[u]random + // use a buffer to avoid overhead of individual crypto calls per byte + var randomBuffer = new Uint8Array(1024), randomLeft = 0; + var randomByte = () => { + if (randomLeft === 0) { + randomLeft = randomFill(randomBuffer).byteLength; + } + return randomBuffer[--randomLeft]; + }; + FS.createDevice("/dev", "random", randomByte); + FS.createDevice("/dev", "urandom", randomByte); + // we're not going to emulate the actual shm device, + // just create the tmp dirs that reside in it commonly + FS.mkdir("/dev/shm"); + FS.mkdir("/dev/shm/tmp"); + }, + createSpecialDirectories() { + // create /proc/self/fd which allows /proc/self/fd/6 => readlink gives the + // name of the stream for fd 6 (see test_unistd_ttyname) + FS.mkdir("/proc"); + var proc_self = FS.mkdir("/proc/self"); + FS.mkdir("/proc/self/fd"); + FS.mount({ + mount() { + var node = FS.createNode(proc_self, "fd", 16384 | 511, /* 0777 */ 73); + node.node_ops = { + lookup(parent, name) { + var fd = +name; + var stream = FS.getStreamChecked(fd); + var ret = { + parent: null, + mount: { + mountpoint: "fake" + }, + node_ops: { + readlink: () => stream.path + } + }; + ret.parent = ret; + // make it look like a simple root node + return ret; + } + }; + return node; + } + }, {}, "/proc/self/fd"); + }, + createStandardStreams(input, output, error) { + // TODO deprecate the old functionality of a single + // input / output callback and that utilizes FS.createDevice + // and instead require a unique set of stream ops + // by default, we symlink the standard streams to the + // default tty devices. however, if the standard streams + // have been overwritten we create a unique device for + // them instead. + if (input) { + FS.createDevice("/dev", "stdin", input); + } else { + FS.symlink("/dev/tty", "/dev/stdin"); + } + if (output) { + FS.createDevice("/dev", "stdout", null, output); + } else { + FS.symlink("/dev/tty", "/dev/stdout"); + } + if (error) { + FS.createDevice("/dev", "stderr", null, error); + } else { + FS.symlink("/dev/tty1", "/dev/stderr"); + } + // open default streams for the stdin, stdout and stderr devices + var stdin = FS.open("/dev/stdin", 0); + var stdout = FS.open("/dev/stdout", 1); + var stderr = FS.open("/dev/stderr", 1); + }, + staticInit() { + // Some errors may happen quite a bit, to avoid overhead we reuse them (and suffer a lack of stack info) + [ 44 ].forEach(code => { + FS.genericErrors[code] = new FS.ErrnoError(code); + FS.genericErrors[code].stack = ""; + }); + FS.nameTable = new Array(4096); + FS.mount(MEMFS, {}, "/"); + FS.createDefaultDirectories(); + FS.createDefaultDevices(); + FS.createSpecialDirectories(); + FS.filesystems = { + "MEMFS": MEMFS, + "WORKERFS": WORKERFS + }; + }, + init(input, output, error) { + FS.initialized = true; + // Allow Module.stdin etc. to provide defaults, if none explicitly passed to us here + input ??= Module["stdin"]; + output ??= Module["stdout"]; + error ??= Module["stderr"]; + FS.createStandardStreams(input, output, error); + }, + quit() { + FS.initialized = false; + // force-flush all streams, so we get musl std streams printed out + // close all of our streams + for (var i = 0; i < FS.streams.length; i++) { + var stream = FS.streams[i]; + if (!stream) { + continue; + } + FS.close(stream); + } + }, + findObject(path, dontResolveLastLink) { + var ret = FS.analyzePath(path, dontResolveLastLink); + if (!ret.exists) { + return null; + } + return ret.object; + }, + analyzePath(path, dontResolveLastLink) { + // operate from within the context of the symlink's target + try { + var lookup = FS.lookupPath(path, { + follow: !dontResolveLastLink + }); + path = lookup.path; + } catch (e) {} + var ret = { + isRoot: false, + exists: false, + error: 0, + name: null, + path: null, + object: null, + parentExists: false, + parentPath: null, + parentObject: null + }; + try { + var lookup = FS.lookupPath(path, { + parent: true + }); + ret.parentExists = true; + ret.parentPath = lookup.path; + ret.parentObject = lookup.node; + ret.name = PATH.basename(path); + lookup = FS.lookupPath(path, { + follow: !dontResolveLastLink + }); + ret.exists = true; + ret.path = lookup.path; + ret.object = lookup.node; + ret.name = lookup.node.name; + ret.isRoot = lookup.path === "/"; + } catch (e) { + ret.error = e.errno; + } + return ret; + }, + createPath(parent, path, canRead, canWrite) { + parent = typeof parent == "string" ? parent : FS.getPath(parent); + var parts = path.split("/").reverse(); + while (parts.length) { + var part = parts.pop(); + if (!part) continue; + var current = PATH.join2(parent, part); + try { + FS.mkdir(current); + } catch (e) {} + // ignore EEXIST + parent = current; + } + return current; + }, + createFile(parent, name, properties, canRead, canWrite) { + var path = PATH.join2(typeof parent == "string" ? parent : FS.getPath(parent), name); + var mode = FS_getMode(canRead, canWrite); + return FS.create(path, mode); + }, + createDataFile(parent, name, data, canRead, canWrite, canOwn) { + var path = name; + if (parent) { + parent = typeof parent == "string" ? parent : FS.getPath(parent); + path = name ? PATH.join2(parent, name) : parent; + } + var mode = FS_getMode(canRead, canWrite); + var node = FS.create(path, mode); + if (data) { + if (typeof data == "string") { + var arr = new Array(data.length); + for (var i = 0, len = data.length; i < len; ++i) arr[i] = data.charCodeAt(i); + data = arr; + } + // make sure we can write to the file + FS.chmod(node, mode | 146); + var stream = FS.open(node, 577); + FS.write(stream, data, 0, data.length, 0, canOwn); + FS.close(stream); + FS.chmod(node, mode); + } + }, + createDevice(parent, name, input, output) { + var path = PATH.join2(typeof parent == "string" ? parent : FS.getPath(parent), name); + var mode = FS_getMode(!!input, !!output); + if (!FS.createDevice.major) FS.createDevice.major = 64; + var dev = FS.makedev(FS.createDevice.major++, 0); + // Create a fake device that a set of stream ops to emulate + // the old behavior. + FS.registerDevice(dev, { + open(stream) { + stream.seekable = false; + }, + close(stream) { + // flush any pending line data + if (output?.buffer?.length) { + output(10); + } + }, + read(stream, buffer, offset, length, pos) { + /* ignored */ var bytesRead = 0; + for (var i = 0; i < length; i++) { + var result; + try { + result = input(); + } catch (e) { + throw new FS.ErrnoError(29); + } + if (result === undefined && bytesRead === 0) { + throw new FS.ErrnoError(6); + } + if (result === null || result === undefined) break; + bytesRead++; + buffer[offset + i] = result; + } + if (bytesRead) { + stream.node.timestamp = Date.now(); + } + return bytesRead; + }, + write(stream, buffer, offset, length, pos) { + for (var i = 0; i < length; i++) { + try { + output(buffer[offset + i]); + } catch (e) { + throw new FS.ErrnoError(29); + } + } + if (length) { + stream.node.timestamp = Date.now(); + } + return i; + } + }); + return FS.mkdev(path, mode, dev); + }, + forceLoadFile(obj) { + if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true; + if (typeof XMLHttpRequest != "undefined") { + throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread."); + } else { + // Command-line. + try { + obj.contents = readBinary(obj.url); + obj.usedBytes = obj.contents.length; + } catch (e) { + throw new FS.ErrnoError(29); + } + } + }, + createLazyFile(parent, name, url, canRead, canWrite) { + // Lazy chunked Uint8Array (implements get and length from Uint8Array). + // Actual getting is abstracted away for eventual reuse. + class LazyUint8Array { + constructor() { + this.lengthKnown = false; + this.chunks = []; + } + // Loaded chunks. Index is the chunk number + get(idx) { + if (idx > this.length - 1 || idx < 0) { + return undefined; + } + var chunkOffset = idx % this.chunkSize; + var chunkNum = (idx / this.chunkSize) | 0; + return this.getter(chunkNum)[chunkOffset]; + } + setDataGetter(getter) { + this.getter = getter; + } + cacheLength() { + // Find length + var xhr = new XMLHttpRequest; + xhr.open("HEAD", url, false); + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error("Couldn't load " + url + ". Status: " + xhr.status); + var datalength = Number(xhr.getResponseHeader("Content-length")); + var header; + var hasByteServing = (header = xhr.getResponseHeader("Accept-Ranges")) && header === "bytes"; + var usesGzip = (header = xhr.getResponseHeader("Content-Encoding")) && header === "gzip"; + var chunkSize = 1024 * 1024; + // Chunk size in bytes + if (!hasByteServing) chunkSize = datalength; + // Function to get a range from the remote URL. + var doXHR = (from, to) => { + if (from > to) throw new Error("invalid range (" + from + ", " + to + ") or no bytes requested!"); + if (to > datalength - 1) throw new Error("only " + datalength + " bytes available! programmer error!"); + // TODO: Use mozResponseArrayBuffer, responseStream, etc. if available. + var xhr = new XMLHttpRequest; + xhr.open("GET", url, false); + if (datalength !== chunkSize) xhr.setRequestHeader("Range", "bytes=" + from + "-" + to); + // Some hints to the browser that we want binary data. + xhr.responseType = "arraybuffer"; + if (xhr.overrideMimeType) { + xhr.overrideMimeType("text/plain; charset=x-user-defined"); + } + xhr.send(null); + if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error("Couldn't load " + url + ". Status: " + xhr.status); + if (xhr.response !== undefined) { + return new Uint8Array(/** @type{Array} */ (xhr.response || [])); + } + return intArrayFromString(xhr.responseText || "", true); + }; + var lazyArray = this; + lazyArray.setDataGetter(chunkNum => { + var start = chunkNum * chunkSize; + var end = (chunkNum + 1) * chunkSize - 1; + // including this byte + end = Math.min(end, datalength - 1); + // if datalength-1 is selected, this is the last block + if (typeof lazyArray.chunks[chunkNum] == "undefined") { + lazyArray.chunks[chunkNum] = doXHR(start, end); + } + if (typeof lazyArray.chunks[chunkNum] == "undefined") throw new Error("doXHR failed!"); + return lazyArray.chunks[chunkNum]; + }); + if (usesGzip || !datalength) { + // if the server uses gzip or doesn't supply the length, we have to download the whole file to get the (uncompressed) length + chunkSize = datalength = 1; + // this will force getter(0)/doXHR do download the whole file + datalength = this.getter(0).length; + chunkSize = datalength; + out("LazyFiles on gzip forces download of the whole file when length is accessed"); + } + this._length = datalength; + this._chunkSize = chunkSize; + this.lengthKnown = true; + } + get length() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._length; + } + get chunkSize() { + if (!this.lengthKnown) { + this.cacheLength(); + } + return this._chunkSize; + } + } + if (typeof XMLHttpRequest != "undefined") { + if (!ENVIRONMENT_IS_WORKER) throw "Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc"; + var lazyArray = new LazyUint8Array; + var properties = { + isDevice: false, + contents: lazyArray + }; + } else { + var properties = { + isDevice: false, + url + }; + } + var node = FS.createFile(parent, name, properties, canRead, canWrite); + // This is a total hack, but I want to get this lazy file code out of the + // core of MEMFS. If we want to keep this lazy file concept I feel it should + // be its own thin LAZYFS proxying calls to MEMFS. + if (properties.contents) { + node.contents = properties.contents; + } else if (properties.url) { + node.contents = null; + node.url = properties.url; + } + // Add a function that defers querying the file size until it is asked the first time. + Object.defineProperties(node, { + usedBytes: { + get: function() { + return this.contents.length; + } + } + }); + // override each stream op with one that tries to force load the lazy file first + var stream_ops = {}; + var keys = Object.keys(node.stream_ops); + keys.forEach(key => { + var fn = node.stream_ops[key]; + stream_ops[key] = (...args) => { + FS.forceLoadFile(node); + return fn(...args); + }; + }); + function writeChunks(stream, buffer, offset, length, position) { + var contents = stream.node.contents; + if (position >= contents.length) return 0; + var size = Math.min(contents.length - position, length); + if (contents.slice) { + // normal array + for (var i = 0; i < size; i++) { + buffer[offset + i] = contents[position + i]; + } + } else { + for (var i = 0; i < size; i++) { + // LazyUint8Array from sync binary XHR + buffer[offset + i] = contents.get(position + i); + } + } + return size; + } + // use a custom read function + stream_ops.read = (stream, buffer, offset, length, position) => { + FS.forceLoadFile(node); + return writeChunks(stream, buffer, offset, length, position); + }; + // use a custom mmap function + stream_ops.mmap = (stream, length, position, prot, flags) => { + FS.forceLoadFile(node); + var ptr = mmapAlloc(length); + if (!ptr) { + throw new FS.ErrnoError(48); + } + writeChunks(stream, HEAP8, ptr, length, position); + return { + ptr, + allocated: true + }; + }; + node.stream_ops = stream_ops; + return node; + } +}; + +Module["FS"] = FS; + +var SYSCALLS = { + DEFAULT_POLLMASK: 5, + calculateAt(dirfd, path, allowEmpty) { + if (PATH.isAbs(path)) { + return path; + } + // relative path + var dir; + if (dirfd === -100) { + dir = FS.cwd(); + } else { + var dirstream = SYSCALLS.getStreamFromFD(dirfd); + dir = dirstream.path; + } + if (path.length == 0) { + if (!allowEmpty) { + throw new FS.ErrnoError(44); + } + return dir; + } + return PATH.join2(dir, path); + }, + doStat(func, path, buf) { + var stat = func(path); + HEAP32[((buf) >> 2)] = stat.dev; + HEAP32[(((buf) + (4)) >> 2)] = stat.mode; + HEAPU32[(((buf) + (8)) >> 2)] = stat.nlink; + HEAP32[(((buf) + (12)) >> 2)] = stat.uid; + HEAP32[(((buf) + (16)) >> 2)] = stat.gid; + HEAP32[(((buf) + (20)) >> 2)] = stat.rdev; + (tempI64 = [ stat.size >>> 0, (tempDouble = stat.size, (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], + HEAP32[(((buf) + (24)) >> 2)] = tempI64[0], HEAP32[(((buf) + (28)) >> 2)] = tempI64[1]); + HEAP32[(((buf) + (32)) >> 2)] = 4096; + HEAP32[(((buf) + (36)) >> 2)] = stat.blocks; + var atime = stat.atime.getTime(); + var mtime = stat.mtime.getTime(); + var ctime = stat.ctime.getTime(); + (tempI64 = [ Math.floor(atime / 1e3) >>> 0, (tempDouble = Math.floor(atime / 1e3), + (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], + HEAP32[(((buf) + (40)) >> 2)] = tempI64[0], HEAP32[(((buf) + (44)) >> 2)] = tempI64[1]); + HEAPU32[(((buf) + (48)) >> 2)] = (atime % 1e3) * 1e3 * 1e3; + (tempI64 = [ Math.floor(mtime / 1e3) >>> 0, (tempDouble = Math.floor(mtime / 1e3), + (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], + HEAP32[(((buf) + (56)) >> 2)] = tempI64[0], HEAP32[(((buf) + (60)) >> 2)] = tempI64[1]); + HEAPU32[(((buf) + (64)) >> 2)] = (mtime % 1e3) * 1e3 * 1e3; + (tempI64 = [ Math.floor(ctime / 1e3) >>> 0, (tempDouble = Math.floor(ctime / 1e3), + (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], + HEAP32[(((buf) + (72)) >> 2)] = tempI64[0], HEAP32[(((buf) + (76)) >> 2)] = tempI64[1]); + HEAPU32[(((buf) + (80)) >> 2)] = (ctime % 1e3) * 1e3 * 1e3; + (tempI64 = [ stat.ino >>> 0, (tempDouble = stat.ino, (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], + HEAP32[(((buf) + (88)) >> 2)] = tempI64[0], HEAP32[(((buf) + (92)) >> 2)] = tempI64[1]); + return 0; + }, + doMsync(addr, stream, len, flags, offset) { + if (!FS.isFile(stream.node.mode)) { + throw new FS.ErrnoError(43); + } + if (flags & 2) { + // MAP_PRIVATE calls need not to be synced back to underlying fs + return 0; + } + var buffer = HEAPU8.slice(addr, addr + len); + FS.msync(stream, buffer, offset, len, flags); + }, + getStreamFromFD(fd) { + var stream = FS.getStreamChecked(fd); + return stream; + }, + varargs: undefined, + getStr(ptr) { + var ret = UTF8ToString(ptr); + return ret; + } +}; + +function ___syscall_fcntl64(fd, cmd, varargs) { + SYSCALLS.varargs = varargs; + try { + var stream = SYSCALLS.getStreamFromFD(fd); + switch (cmd) { + case 0: + { + var arg = syscallGetVarargI(); + if (arg < 0) { + return -28; + } + while (FS.streams[arg]) { + arg++; + } + var newStream; + newStream = FS.dupStream(stream, arg); + return newStream.fd; + } + + case 1: + case 2: + return 0; + + // FD_CLOEXEC makes no sense for a single process. + case 3: + return stream.flags; + + case 4: + { + var arg = syscallGetVarargI(); + stream.flags |= arg; + return 0; + } + + case 12: + { + var arg = syscallGetVarargP(); + var offset = 0; + // We're always unlocked. + HEAP16[(((arg) + (offset)) >> 1)] = 2; + return 0; + } + + case 13: + case 14: + return 0; + } + // Pretend that the locking is successful. + return -28; + } catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return -e.errno; + } +} + +function ___syscall_ioctl(fd, op, varargs) { + SYSCALLS.varargs = varargs; + try { + var stream = SYSCALLS.getStreamFromFD(fd); + switch (op) { + case 21509: + { + if (!stream.tty) return -59; + return 0; + } + + case 21505: + { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcgets) { + var termios = stream.tty.ops.ioctl_tcgets(stream); + var argp = syscallGetVarargP(); + HEAP32[((argp) >> 2)] = termios.c_iflag || 0; + HEAP32[(((argp) + (4)) >> 2)] = termios.c_oflag || 0; + HEAP32[(((argp) + (8)) >> 2)] = termios.c_cflag || 0; + HEAP32[(((argp) + (12)) >> 2)] = termios.c_lflag || 0; + for (var i = 0; i < 32; i++) { + HEAP8[(argp + i) + (17)] = termios.c_cc[i] || 0; + } + return 0; + } + return 0; + } + + case 21510: + case 21511: + case 21512: + { + if (!stream.tty) return -59; + return 0; + } + + // no-op, not actually adjusting terminal settings + case 21506: + case 21507: + case 21508: + { + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tcsets) { + var argp = syscallGetVarargP(); + var c_iflag = HEAP32[((argp) >> 2)]; + var c_oflag = HEAP32[(((argp) + (4)) >> 2)]; + var c_cflag = HEAP32[(((argp) + (8)) >> 2)]; + var c_lflag = HEAP32[(((argp) + (12)) >> 2)]; + var c_cc = []; + for (var i = 0; i < 32; i++) { + c_cc.push(HEAP8[(argp + i) + (17)]); + } + return stream.tty.ops.ioctl_tcsets(stream.tty, op, { + c_iflag, + c_oflag, + c_cflag, + c_lflag, + c_cc + }); + } + return 0; + } + + // no-op, not actually adjusting terminal settings + case 21519: + { + if (!stream.tty) return -59; + var argp = syscallGetVarargP(); + HEAP32[((argp) >> 2)] = 0; + return 0; + } + + case 21520: + { + if (!stream.tty) return -59; + return -28; + } + + // not supported + case 21531: + { + var argp = syscallGetVarargP(); + return FS.ioctl(stream, op, argp); + } + + case 21523: + { + // TODO: in theory we should write to the winsize struct that gets + // passed in, but for now musl doesn't read anything on it + if (!stream.tty) return -59; + if (stream.tty.ops.ioctl_tiocgwinsz) { + var winsize = stream.tty.ops.ioctl_tiocgwinsz(stream.tty); + var argp = syscallGetVarargP(); + HEAP16[((argp) >> 1)] = winsize[0]; + HEAP16[(((argp) + (2)) >> 1)] = winsize[1]; + } + return 0; + } + + case 21524: + { + // TODO: technically, this ioctl call should change the window size. + // but, since emscripten doesn't have any concept of a terminal window + // yet, we'll just silently throw it away as we do TIOCGWINSZ + if (!stream.tty) return -59; + return 0; + } + + case 21515: + { + if (!stream.tty) return -59; + return 0; + } + + default: + return -28; + } + } // not supported + catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return -e.errno; + } +} + +function ___syscall_openat(dirfd, path, flags, varargs) { + SYSCALLS.varargs = varargs; + try { + path = SYSCALLS.getStr(path); + path = SYSCALLS.calculateAt(dirfd, path); + var mode = varargs ? syscallGetVarargI() : 0; + return FS.open(path, flags, mode).fd; + } catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return -e.errno; + } +} + +var __abort_js = () => { + abort(""); +}; + +var nowIsMonotonic = 1; + +var __emscripten_get_now_is_monotonic = () => nowIsMonotonic; + +var __emscripten_memcpy_js = (dest, src, num) => HEAPU8.copyWithin(dest, src, src + num); + +var stringToUTF8 = (str, outPtr, maxBytesToWrite) => stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + +var __tzset_js = (timezone, daylight, std_name, dst_name) => { + // TODO: Use (malleable) environment variables instead of system settings. + var currentYear = (new Date).getFullYear(); + var winter = new Date(currentYear, 0, 1); + var summer = new Date(currentYear, 6, 1); + var winterOffset = winter.getTimezoneOffset(); + var summerOffset = summer.getTimezoneOffset(); + // Local standard timezone offset. Local standard time is not adjusted for + // daylight savings. This code uses the fact that getTimezoneOffset returns + // a greater value during Standard Time versus Daylight Saving Time (DST). + // Thus it determines the expected output during Standard Time, and it + // compares whether the output of the given date the same (Standard) or less + // (DST). + var stdTimezoneOffset = Math.max(winterOffset, summerOffset); + // timezone is specified as seconds west of UTC ("The external variable + // `timezone` shall be set to the difference, in seconds, between + // Coordinated Universal Time (UTC) and local standard time."), the same + // as returned by stdTimezoneOffset. + // See http://pubs.opengroup.org/onlinepubs/009695399/functions/tzset.html + HEAPU32[((timezone) >> 2)] = stdTimezoneOffset * 60; + HEAP32[((daylight) >> 2)] = Number(winterOffset != summerOffset); + var extractZone = timezoneOffset => { + // Why inverse sign? + // Read here https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset + var sign = timezoneOffset >= 0 ? "-" : "+"; + var absOffset = Math.abs(timezoneOffset); + var hours = String(Math.floor(absOffset / 60)).padStart(2, "0"); + var minutes = String(absOffset % 60).padStart(2, "0"); + return `UTC${sign}${hours}${minutes}`; + }; + var winterName = extractZone(winterOffset); + var summerName = extractZone(summerOffset); + if (summerOffset < winterOffset) { + // Northern hemisphere + stringToUTF8(winterName, std_name, 17); + stringToUTF8(summerName, dst_name, 17); + } else { + stringToUTF8(winterName, dst_name, 17); + stringToUTF8(summerName, std_name, 17); + } +}; + +var _emscripten_date_now = () => Date.now(); + +var _emscripten_get_now; + +// Modern environment where performance.now() is supported: +// N.B. a shorter form "_emscripten_get_now = performance.now;" is +// unfortunately not allowed even in current browsers (e.g. FF Nightly 75). +_emscripten_get_now = () => performance.now(); + +var getHeapMax = () => // Stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate +// full 4GB Wasm memories, the size will wrap back to 0 bytes in Wasm side +// for any code that deals with heap sizes, which would require special +// casing all heap size related code to treat 0 specially. +2147483648; + +var growMemory = size => { + var b = wasmMemory.buffer; + var pages = (size - b.byteLength + 65535) / 65536; + try { + // round size grow request up to wasm page size (fixed 64KB per spec) + wasmMemory.grow(pages); + // .grow() takes a delta compared to the previous size + updateMemoryViews(); + return 1; + } /*success*/ catch (e) {} +}; + +// implicit 0 return to save code size (caller will cast "undefined" into 0 +// anyhow) +var _emscripten_resize_heap = requestedSize => { + var oldSize = HEAPU8.length; + // With CAN_ADDRESS_2GB or MEMORY64, pointers are already unsigned. + requestedSize >>>= 0; + // With multithreaded builds, races can happen (another thread might increase the size + // in between), so return a failure, and let the caller retry. + // Memory resize rules: + // 1. Always increase heap size to at least the requested size, rounded up + // to next page multiple. + // 2a. If MEMORY_GROWTH_LINEAR_STEP == -1, excessively resize the heap + // geometrically: increase the heap size according to + // MEMORY_GROWTH_GEOMETRIC_STEP factor (default +20%), At most + // overreserve by MEMORY_GROWTH_GEOMETRIC_CAP bytes (default 96MB). + // 2b. If MEMORY_GROWTH_LINEAR_STEP != -1, excessively resize the heap + // linearly: increase the heap size by at least + // MEMORY_GROWTH_LINEAR_STEP bytes. + // 3. Max size for the heap is capped at 2048MB-WASM_PAGE_SIZE, or by + // MAXIMUM_MEMORY, or by ASAN limit, depending on which is smallest + // 4. If we were unable to allocate as much memory, it may be due to + // over-eager decision to excessively reserve due to (3) above. + // Hence if an allocation fails, cut down on the amount of excess + // growth, in an attempt to succeed to perform a smaller allocation. + // A limit is set for how much we can grow. We should not exceed that + // (the wasm binary specifies it, so if we tried, we'd fail anyhow). + var maxHeapSize = getHeapMax(); + if (requestedSize > maxHeapSize) { + return false; + } + // Loop through potential heap size increases. If we attempt a too eager + // reservation that fails, cut down on the attempted size and reserve a + // smaller bump instead. (max 3 times, chosen somewhat arbitrarily) + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + .2 / cutDown); + // ensure geometric growth + // but limit overreserving (default to capping at +96MB overgrowth at most) + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296); + var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536)); + var replacement = growMemory(newSize); + if (replacement) { + return true; + } + } + return false; +}; + +var ENV = {}; + +var getExecutableName = () => thisProgram || "./this.program"; + +var getEnvStrings = () => { + if (!getEnvStrings.strings) { + // Default values. + // Browser language detection #8751 + var lang = ((typeof navigator == "object" && navigator.languages && navigator.languages[0]) || "C").replace("-", "_") + ".UTF-8"; + var env = { + "USER": "web_user", + "LOGNAME": "web_user", + "PATH": "/", + "PWD": "/", + "HOME": "/home/web_user", + "LANG": lang, + "_": getExecutableName() + }; + // Apply the user-provided values, if any. + for (var x in ENV) { + // x is a key in ENV; if ENV[x] is undefined, that means it was + // explicitly set to be so. We allow user code to do that to + // force variables with default values to remain unset. + if (ENV[x] === undefined) delete env[x]; else env[x] = ENV[x]; + } + var strings = []; + for (var x in env) { + strings.push(`${x}=${env[x]}`); + } + getEnvStrings.strings = strings; + } + return getEnvStrings.strings; +}; + +var stringToAscii = (str, buffer) => { + for (var i = 0; i < str.length; ++i) { + HEAP8[buffer++] = str.charCodeAt(i); + } + // Null-terminate the string + HEAP8[buffer] = 0; +}; + +var _environ_get = (__environ, environ_buf) => { + var bufSize = 0; + getEnvStrings().forEach((string, i) => { + var ptr = environ_buf + bufSize; + HEAPU32[(((__environ) + (i * 4)) >> 2)] = ptr; + stringToAscii(string, ptr); + bufSize += string.length + 1; + }); + return 0; +}; + +var _environ_sizes_get = (penviron_count, penviron_buf_size) => { + var strings = getEnvStrings(); + HEAPU32[((penviron_count) >> 2)] = strings.length; + var bufSize = 0; + strings.forEach(string => bufSize += string.length + 1); + HEAPU32[((penviron_buf_size) >> 2)] = bufSize; + return 0; +}; + +function _fd_close(fd) { + try { + var stream = SYSCALLS.getStreamFromFD(fd); + FS.close(stream); + return 0; + } catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return e.errno; + } +} + +/** @param {number=} offset */ var doReadv = (stream, iov, iovcnt, offset) => { + var ret = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[((iov) >> 2)]; + var len = HEAPU32[(((iov) + (4)) >> 2)]; + iov += 8; + var curr = FS.read(stream, HEAP8, ptr, len, offset); + if (curr < 0) return -1; + ret += curr; + if (curr < len) break; + // nothing more to read + if (typeof offset != "undefined") { + offset += curr; + } + } + return ret; +}; + +function _fd_read(fd, iov, iovcnt, pnum) { + try { + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doReadv(stream, iov, iovcnt); + HEAPU32[((pnum) >> 2)] = num; + return 0; + } catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return e.errno; + } +} + +var convertI32PairToI53Checked = (lo, hi) => ((hi + 2097152) >>> 0 < 4194305 - !!lo) ? (lo >>> 0) + hi * 4294967296 : NaN; + +function _fd_seek(fd, offset_low, offset_high, whence, newOffset) { + var offset = convertI32PairToI53Checked(offset_low, offset_high); + try { + if (isNaN(offset)) return 61; + var stream = SYSCALLS.getStreamFromFD(fd); + FS.llseek(stream, offset, whence); + (tempI64 = [ stream.position >>> 0, (tempDouble = stream.position, (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], + HEAP32[((newOffset) >> 2)] = tempI64[0], HEAP32[(((newOffset) + (4)) >> 2)] = tempI64[1]); + if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null; + // reset readdir state + return 0; + } catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return e.errno; + } +} + +/** @param {number=} offset */ var doWritev = (stream, iov, iovcnt, offset) => { + var ret = 0; + for (var i = 0; i < iovcnt; i++) { + var ptr = HEAPU32[((iov) >> 2)]; + var len = HEAPU32[(((iov) + (4)) >> 2)]; + iov += 8; + var curr = FS.write(stream, HEAP8, ptr, len, offset); + if (curr < 0) return -1; + ret += curr; + if (curr < len) { + // No more space to write. + break; + } + if (typeof offset != "undefined") { + offset += curr; + } + } + return ret; +}; + +function _fd_write(fd, iov, iovcnt, pnum) { + try { + var stream = SYSCALLS.getStreamFromFD(fd); + var num = doWritev(stream, iov, iovcnt); + HEAPU32[((pnum) >> 2)] = num; + return 0; + } catch (e) { + if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e; + return e.errno; + } +} + +var getCFunc = ident => { + var func = Module["_" + ident]; + // closure exported function + return func; +}; + +var writeArrayToMemory = (array, buffer) => { + HEAP8.set(array, buffer); +}; + +var stackAlloc = sz => __emscripten_stack_alloc(sz); + +var stringToUTF8OnStack = str => { + var size = lengthBytesUTF8(str) + 1; + var ret = stackAlloc(size); + stringToUTF8(str, ret, size); + return ret; +}; + +/** + * @param {string|null=} returnType + * @param {Array=} argTypes + * @param {Arguments|Array=} args + * @param {Object=} opts + */ var ccall = (ident, returnType, argTypes, args, opts) => { + // For fast lookup of conversion functions + var toC = { + "string": str => { + var ret = 0; + if (str !== null && str !== undefined && str !== 0) { + // null string + // at most 4 bytes per UTF-8 code point, +1 for the trailing '\0' + ret = stringToUTF8OnStack(str); + } + return ret; + }, + "array": arr => { + var ret = stackAlloc(arr.length); + writeArrayToMemory(arr, ret); + return ret; + } + }; + function convertReturnValue(ret) { + if (returnType === "string") { + return UTF8ToString(ret); + } + if (returnType === "boolean") return Boolean(ret); + return ret; + } + var func = getCFunc(ident); + var cArgs = []; + var stack = 0; + if (args) { + for (var i = 0; i < args.length; i++) { + var converter = toC[argTypes[i]]; + if (converter) { + if (stack === 0) stack = stackSave(); + cArgs[i] = converter(args[i]); + } else { + cArgs[i] = args[i]; + } + } + } + var ret = func(...cArgs); + function onDone(ret) { + if (stack !== 0) stackRestore(stack); + return convertReturnValue(ret); + } + ret = onDone(ret); + return ret; +}; + +/** + * @param {string=} returnType + * @param {Array=} argTypes + * @param {Object=} opts + */ var cwrap = (ident, returnType, argTypes, opts) => { + // When the function takes numbers and returns a number, we can just return + // the original function + var numericArgs = !argTypes || argTypes.every(type => type === "number" || type === "boolean"); + var numericRet = returnType !== "string"; + if (numericRet && numericArgs && !opts) { + return getCFunc(ident); + } + return (...args) => ccall(ident, returnType, argTypes, args, opts); +}; + +var stringToNewUTF8 = str => { + var size = lengthBytesUTF8(str) + 1; + var ret = _malloc(size); + if (ret) stringToUTF8(str, ret, size); + return ret; +}; + +FS.createPreloadedFile = FS_createPreloadedFile; + +FS.staticInit(); + +var wasmImports = { + /** @export */ __assert_fail: ___assert_fail, + /** @export */ __cxa_throw: ___cxa_throw, + /** @export */ __syscall_fcntl64: ___syscall_fcntl64, + /** @export */ __syscall_ioctl: ___syscall_ioctl, + /** @export */ __syscall_openat: ___syscall_openat, + /** @export */ _abort_js: __abort_js, + /** @export */ _emscripten_get_now_is_monotonic: __emscripten_get_now_is_monotonic, + /** @export */ _emscripten_memcpy_js: __emscripten_memcpy_js, + /** @export */ _tzset_js: __tzset_js, + /** @export */ emscripten_date_now: _emscripten_date_now, + /** @export */ emscripten_get_now: _emscripten_get_now, + /** @export */ emscripten_resize_heap: _emscripten_resize_heap, + /** @export */ environ_get: _environ_get, + /** @export */ environ_sizes_get: _environ_sizes_get, + /** @export */ fd_close: _fd_close, + /** @export */ fd_read: _fd_read, + /** @export */ fd_seek: _fd_seek, + /** @export */ fd_write: _fd_write +}; + +var wasmExports = createWasm(); + +var ___wasm_call_ctors = () => (___wasm_call_ctors = wasmExports["__wasm_call_ctors"])(); + +var _Hunspell_create = Module["_Hunspell_create"] = (a0, a1) => (_Hunspell_create = Module["_Hunspell_create"] = wasmExports["Hunspell_create"])(a0, a1); + +var _Hunspell_destroy = Module["_Hunspell_destroy"] = a0 => (_Hunspell_destroy = Module["_Hunspell_destroy"] = wasmExports["Hunspell_destroy"])(a0); + +var _Hunspell_add_dic = Module["_Hunspell_add_dic"] = (a0, a1) => (_Hunspell_add_dic = Module["_Hunspell_add_dic"] = wasmExports["Hunspell_add_dic"])(a0, a1); + +var _Hunspell_spell = Module["_Hunspell_spell"] = (a0, a1) => (_Hunspell_spell = Module["_Hunspell_spell"] = wasmExports["Hunspell_spell"])(a0, a1); + +var _Hunspell_suggest = Module["_Hunspell_suggest"] = (a0, a1, a2) => (_Hunspell_suggest = Module["_Hunspell_suggest"] = wasmExports["Hunspell_suggest"])(a0, a1, a2); + +var _Hunspell_add = Module["_Hunspell_add"] = (a0, a1) => (_Hunspell_add = Module["_Hunspell_add"] = wasmExports["Hunspell_add"])(a0, a1); + +var _Hunspell_remove = Module["_Hunspell_remove"] = (a0, a1) => (_Hunspell_remove = Module["_Hunspell_remove"] = wasmExports["Hunspell_remove"])(a0, a1); + +var _Hunspell_free_list = Module["_Hunspell_free_list"] = (a0, a1, a2) => (_Hunspell_free_list = Module["_Hunspell_free_list"] = wasmExports["Hunspell_free_list"])(a0, a1, a2); + +var _malloc = Module["_malloc"] = a0 => (_malloc = Module["_malloc"] = wasmExports["malloc"])(a0); + +var _free = Module["_free"] = a0 => (_free = Module["_free"] = wasmExports["free"])(a0); + +var __emscripten_stack_restore = a0 => (__emscripten_stack_restore = wasmExports["_emscripten_stack_restore"])(a0); + +var __emscripten_stack_alloc = a0 => (__emscripten_stack_alloc = wasmExports["_emscripten_stack_alloc"])(a0); + +var _emscripten_stack_get_current = () => (_emscripten_stack_get_current = wasmExports["emscripten_stack_get_current"])(); + +var dynCall_iiiiij = Module["dynCall_iiiiij"] = (a0, a1, a2, a3, a4, a5, a6) => (dynCall_iiiiij = Module["dynCall_iiiiij"] = wasmExports["dynCall_iiiiij"])(a0, a1, a2, a3, a4, a5, a6); + +var dynCall_iiiiijj = Module["dynCall_iiiiijj"] = (a0, a1, a2, a3, a4, a5, a6, a7, a8) => (dynCall_iiiiijj = Module["dynCall_iiiiijj"] = wasmExports["dynCall_iiiiijj"])(a0, a1, a2, a3, a4, a5, a6, a7, a8); + +var dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = (a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) => (dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = wasmExports["dynCall_iiiiiijj"])(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9); + +var dynCall_jiji = Module["dynCall_jiji"] = (a0, a1, a2, a3, a4) => (dynCall_jiji = Module["dynCall_jiji"] = wasmExports["dynCall_jiji"])(a0, a1, a2, a3, a4); + +var dynCall_viijii = Module["dynCall_viijii"] = (a0, a1, a2, a3, a4, a5, a6) => (dynCall_viijii = Module["dynCall_viijii"] = wasmExports["dynCall_viijii"])(a0, a1, a2, a3, a4, a5, a6); + +// include: postamble.js +// === Auto-generated postamble setup entry stuff === +Module["ccall"] = ccall; + +Module["cwrap"] = cwrap; + +Module["getValue"] = getValue; + +Module["UTF8ToString"] = UTF8ToString; + +Module["stringToNewUTF8"] = stringToNewUTF8; + +Module["WORKERFS"] = WORKERFS; + +var calledRun; + +dependenciesFulfilled = function runCaller() { + // If run has never been called, and we should call run (INVOKE_RUN is true, and Module.noInitialRun is not false) + if (!calledRun) run(); + if (!calledRun) dependenciesFulfilled = runCaller; +}; + +// try this again later, after new deps are fulfilled +function run() { + if (runDependencies > 0) { + return; + } + preRun(); + // a preRun added a dependency, run will be called later + if (runDependencies > 0) { + return; + } + function doRun() { + // run may have just been called through dependencies being fulfilled just in this very frame, + // or while the async setStatus time below was happening + if (calledRun) return; + calledRun = true; + Module["calledRun"] = true; + if (ABORT) return; + initRuntime(); + readyPromiseResolve(Module); + Module["onRuntimeInitialized"]?.(); + postRun(); + } + if (Module["setStatus"]) { + Module["setStatus"]("Running..."); + setTimeout(() => { + setTimeout(() => Module["setStatus"](""), 1); + doRun(); + }, 1); + } else { + doRun(); + } +} + +if (Module["preInit"]) { + if (typeof Module["preInit"] == "function") Module["preInit"] = [ Module["preInit"] ]; + while (Module["preInit"].length > 0) { + Module["preInit"].pop()(); + } +} + +run(); + +// end include: postamble.js +// include: postamble_modularize.js +// In MODULARIZE mode we wrap the generated code in a factory function +// and return either the Module itself, or a promise of the module. +// We assign to the `moduleRtn` global here and configure closure to see +// this as and extern so it won't get minified. +moduleRtn = readyPromise; + + + return moduleRtn; +} +); +})(); +export default Module; diff --git a/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.wasm b/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.wasm new file mode 100644 index 0000000000..9ac85110d0 Binary files /dev/null and b/services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.wasm differ diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 4ae6e3fb68..b6c110ae2e 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -54,6 +54,7 @@ export interface Meta { 'ol-allowedExperiments': string[] 'ol-allowedImageNames': AllowedImageName[] 'ol-anonymous': boolean + 'ol-baseAssetPath': string 'ol-bootstrapVersion': 3 | 5 'ol-brandVariation': Record @@ -76,6 +77,7 @@ export interface Meta { 'ol-currentUrl': string 'ol-debugPdfDetach': boolean 'ol-detachRole': 'detached' | 'detacher' | '' + 'ol-dictionariesRoot': 'string' 'ol-dropbox': { error: boolean; registered: boolean } 'ol-editorThemes': string[] 'ol-email': string diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 87a2567341..804525c50a 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -77,6 +77,7 @@ "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__", "add_people": "Add people", "add_role_and_department": "Add role and department", + "add_to_dictionary": "Add to Dictionary", "add_to_tag": "Add to tag", "add_your_comment_here": "Add your comment here", "add_your_first_group_member_now": "Add your first group members now", diff --git a/services/web/package.json b/services/web/package.json index 6d4ac29db1..d860fd915a 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -76,6 +76,7 @@ "@node-oauth/oauth2-server": "^5.1.0", "@node-saml/passport-saml": "^4.0.4", "@overleaf/access-token-encryptor": "*", + "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz", "@overleaf/fetch-utils": "*", "@overleaf/logger": "*", "@overleaf/metrics": "*", diff --git a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx new file mode 100644 index 0000000000..37875012a5 --- /dev/null +++ b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx @@ -0,0 +1,100 @@ +import DictionaryModal from '@/features/dictionary/components/dictionary-modal' +import { EditorProviders } from '../../../helpers/editor-providers' + +describe('', function () { + beforeEach(function () { + cy.interceptCompile() + }) + + afterEach(function () { + cy.window().then(win => { + win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + }) + }) + + it('list words', function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-learnedWords', ['foo', 'bar']) + win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + }) + + cy.mount( + + + + ) + + cy.findByText('foo') + cy.findByText('bar') + }) + + it('shows message when empty', function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-learnedWords', []) + win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + }) + + cy.mount( + + + + ) + + cy.contains('Your custom dictionary is empty.') + }) + + it('removes words', function () { + cy.intercept('/spelling/unlearn', { statusCode: 200 }) + + cy.window().then(win => { + win.metaAttributesCache.set('ol-learnedWords', ['Foo', 'bar']) + win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + }) + + cy.mount( + + + + ) + + cy.findByText('Foo') + cy.findByText('bar') + + cy.findAllByRole('button', { + name: 'Remove from dictionary', + }) + .eq(0) + .click() + + cy.findByText('bar').should('not.exist') + cy.findByText('Foo') + }) + + it('handles errors', function () { + cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn') + + cy.window().then(win => { + win.metaAttributesCache.set('ol-learnedWords', ['foo']) + win.dispatchEvent(new CustomEvent('learnedWords:doreset')) + }) + + cy.mount( + + + + ) + + cy.findByText('foo') + + cy.findAllByRole('button', { + name: 'Remove from dictionary', + }) + .eq(0) + .click() + + cy.wait('@unlearn') + + cy.findByText('Sorry, something went wrong') + cy.findByText('foo') + }) +}) diff --git a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.jsx b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.jsx deleted file mode 100644 index 63a8202403..0000000000 --- a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - screen, - fireEvent, - waitForElementToBeRemoved, -} from '@testing-library/react' -import fetchMock from 'fetch-mock' -import DictionaryModal from '../../../../../frontend/js/features/dictionary/components/dictionary-modal' -import { renderWithEditorContext } from '../../../helpers/render-with-context' - -function setLearnedWords(words) { - window.metaAttributesCache.set('ol-learnedWords', words) - window.dispatchEvent(new CustomEvent('learnedWords:doreset')) -} -describe('', function () { - afterEach(function () { - fetchMock.reset() - setLearnedWords([]) - }) - - it('list words', async function () { - setLearnedWords(['foo', 'bar']) - renderWithEditorContext( {}} />) - screen.getByText('foo') - screen.getByText('bar') - }) - - it('shows message when empty', async function () { - setLearnedWords([]) - renderWithEditorContext( {}} />) - screen.getByText('Your custom dictionary is empty.') - }) - - it('removes words', async function () { - fetchMock.post('/spelling/unlearn', 200) - setLearnedWords(['Foo', 'bar']) - renderWithEditorContext( {}} />) - screen.getByText('Foo') - screen.getByText('bar') - const [firstButton] = screen.getAllByRole('button', { - name: 'Remove from dictionary', - }) - fireEvent.click(firstButton) - await waitForElementToBeRemoved(() => screen.getByText('bar')) - screen.getByText('Foo') - }) - - it('handles errors', async function () { - fetchMock.post('/spelling/unlearn', 500) - setLearnedWords(['foo']) - renderWithEditorContext( {}} />) - screen.getByText('foo') - const [firstButton] = screen.getAllByRole('button', { - name: 'Remove from dictionary', - }) - fireEvent.click(firstButton) - await fetchMock.flush() - screen.getByText('Sorry, something went wrong') - screen.getByText('foo') - }) -}) diff --git a/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx new file mode 100644 index 0000000000..20ed94f322 --- /dev/null +++ b/services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx @@ -0,0 +1,85 @@ +import '../../../helpers/bootstrap-3' +import { mockScope } from '../helpers/mock-scope' +import { EditorProviders } from '../../../helpers/editor-providers' +import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor' +import { TestContainer } from '../helpers/test-container' +import forEach from 'mocha-each' +import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions' + +const languages = [ + { code: 'en_GB', dic: 'en_GB', name: 'English (British)' }, + { code: 'fr', dic: 'fr', name: 'French' }, + { code: 'sv', dic: 'sv_SE', name: 'Swedish' }, +] + +const suggestions = { + en_GB: ['medecine', 'medicine'], + fr: ['medecin', 'médecin'], + sv: ['medecin', 'medicin'], +} + +forEach(Object.keys(suggestions)).describe( + 'Spell check in client (%s)', + (spellCheckLanguage: keyof typeof suggestions) => { + const content = ` +\\documentclass{} + +\\title{} +\\author{} + +\\begin{document} +\\maketitle + +\\begin{abstract} +\\end{abstract} + +\\section{} + +\\end{document}` + + beforeEach(function () { + cy.window().then(win => { + win.metaAttributesCache.set('ol-preventCompileOnLoad', true) + win.metaAttributesCache.set('ol-splitTestVariants', { + 'spell-check-client': 'enabled', + }) + win.metaAttributesCache.set('ol-splitTestInfo', {}) + win.metaAttributesCache.set('ol-learnedWords', ['baz']) + win.metaAttributesCache.set( + 'ol-dictionariesRoot', + `js/dictionaries/${PackageVersions.version.dictionaries}/` + ) + win.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/') + win.metaAttributesCache.set('ol-languages', languages) + }) + + cy.interceptEvents() + + const scope = mockScope(content) + scope.project.spellCheckLanguage = spellCheckLanguage + + cy.mount( + + + + + + ) + + cy.get('.cm-line').eq(13).as('line') + cy.get('@line').click() + }) + + it('shows suggestions for misspelled word', function () { + const [from, to] = suggestions[spellCheckLanguage] + + cy.get('@line').type(from) + cy.get('@line').get('.ol-cm-spelling-error').contains(from) + + cy.get('@line').get('.ol-cm-spelling-error').rightclick() + cy.findByText(to).click() + cy.get('@line').contains(to) + cy.get('@line').find('.ol-cm-spelling-error').should('not.exist') + }) + } +) diff --git a/services/web/types/project-settings.ts b/services/web/types/project-settings.ts index 13e49d7757..f256a038c2 100644 --- a/services/web/types/project-settings.ts +++ b/services/web/types/project-settings.ts @@ -30,4 +30,6 @@ export type PdfViewer = 'pdfjs' | 'native' export type SpellCheckLanguage = { name: string code: string + dic?: string + server?: false } diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 002cfda877..8f6f47a4a9 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -66,6 +66,7 @@ function getModuleDirectory(moduleName) { } const mathjaxDir = getModuleDirectory('mathjax') +const dictionariesDir = getModuleDirectory('@overleaf/dictionaries') const pdfjsVersions = ['pdfjs-dist213', 'pdfjs-dist401'] @@ -78,6 +79,14 @@ if (MATHJAX_VERSION !== PackageVersions.version.mathjax) { ) } +const DICTIONARIES_VERSION = + require('@overleaf/dictionaries/package.json').version +if (DICTIONARIES_VERSION !== PackageVersions.version.dictionaries) { + throw new Error( + '"@overleaf/dictionaries" version de-synced, update services/web/app/src/infrastructure/PackageVersions.js' + ) +} + module.exports = { // Defines the "entry point(s)" for the application - i.e. the file which // bootstraps the application @@ -141,6 +150,13 @@ module.exports = { ], type: 'javascript/auto', }, + { + test: /\.wasm$/, + type: 'asset/resource', + generator: { + filename: 'js/[name]-[contenthash][ext]', + }, + }, { // Pass Less files through less-loader/css-loader/mini-css-extract- // plugin (note: run in reverse order) @@ -275,6 +291,10 @@ module.exports = { }, }, + experiments: { + asyncWebAssembly: true, + }, + plugins: [ new LezerGrammarCompilerPlugin(), @@ -346,6 +366,12 @@ module.exports = { toType: 'dir', context: mathjaxDir, }, + { + from: '*', + to: `js/dictionaries/${PackageVersions.version.dictionaries}`, + toType: 'dir', + context: `${dictionariesDir}/dictionaries`, + }, ...pdfjsVersions.flatMap(version => { const dir = getModuleDirectory(version)