From aaf0235287bfeba72a7d74f7d5efb8bb570efebf Mon Sep 17 00:00:00 2001 From: Vladimir Y <1560781+vladimiry@users.noreply.github.com> Date: Sat, 20 Mar 2021 16:43:07 +0300 Subject: [PATCH] enable support for javascript-based search filters, closes #257 * filter function typed in TypeScript * Monaco Editor used as a code editor --- README.md | 5 +- package.json | 14 +- scripts/lib.ts | 13 + scripts/prepare-webclient/index.ts | 22 ++ .../prepare-webclient/monaco-editor-dts.ts | 81 +++++ .../{lib.ts => protonmail-lib.ts} | 55 ++-- scripts/prepare-webclient/protonmail.ts | 22 +- .../endpoints-builders/database/search/api.ts | 123 ++------ .../database/search/service.ts | 145 ++++++++- src/electron-main/api/index.ts | 21 +- .../database-indexer/index.ts | 26 +- src/shared/api/main.ts | 20 +- src/shared/constants.ts | 10 +- src/shared/proton-apps-constants.ts | 5 + src/web/browser-window/@types/index.d.ts | 2 - .../_db-view/db-view-mail-body.component.html | 2 +- .../_db-view/db-view-mail-body.component.ts | 10 +- .../db-view-mails-search.component.html | 182 ++++++----- .../db-view-mails-search.component.scss | 8 + .../db-view-mails-search.component.ts | 18 +- .../db-view-monaco-editor.component.html | 23 ++ .../db-view-monaco-editor.component.scss | 10 + .../db-view-monaco-editor.component.ts | 296 ++++++++++++++++++ .../app/_db-view/db-view.effects.ts | 49 +-- .../app/_db-view/db-view.module.ts | 2 + .../app/_options/account-edit.component.html | 2 +- .../app/_options/base-settings.component.html | 5 +- src/web/browser-window/index.scss | 8 + .../vendor/electron-webview-angular-fix.ts | 36 --- src/web/browser-window/vendor/index.ts | 1 - webpack-configs/model.ts | 5 +- webpack-configs/web/browser-window.ts | 47 ++- webpack-configs/web/lib.ts | 8 +- yarn.lock | 32 ++ 34 files changed, 961 insertions(+), 347 deletions(-) create mode 100644 scripts/prepare-webclient/index.ts create mode 100644 scripts/prepare-webclient/monaco-editor-dts.ts rename scripts/prepare-webclient/{lib.ts => protonmail-lib.ts} (90%) create mode 100644 src/web/browser-window/app/_db-view/db-view-monaco-editor.component.html create mode 100644 src/web/browser-window/app/_db-view/db-view-monaco-editor.component.scss create mode 100644 src/web/browser-window/app/_db-view/db-view-monaco-editor.component.ts delete mode 100644 src/web/browser-window/vendor/electron-webview-angular-fix.ts diff --git a/README.md b/README.md index f0a0634bc..96f8fb962 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,8 @@ Some Linux package types are available for installing from the repositories (`Pa - :octocat: **Open Source**. - :gear: **Reproducible builds**. See details in [#183](https://github.com/vladimiry/ElectronMail/issues/183). - :gear: **Cross platform**. The app works on Linux/OSX/Windows platforms. Binary installation packages located [here](https://github.com/vladimiry/ElectronMail/releases). -- :mag_right: **Full-text search**. Including email body content scanning capability. Enabled with [v2.2.0](https://github.com/vladimiry/ElectronMail/releases/tag/v2.2.0) release. See the respective [issue](https://github.com/vladimiry/ElectronMail/issues/92) for details. +- :mag_right: **Full-text search**. Including email **body content** scanning capability. Enabled with [v2.2.0](https://github.com/vladimiry/ElectronMail/releases/tag/v2.2.0) release. See the respective [issue](https://github.com/vladimiry/ElectronMail/issues/92) for details. +- :mag_right: **JavaScript-based/unlimited messages filtering**. Enabled since [v4.11.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.11.0) release. See the respective [#257](https://github.com/vladimiry/ElectronMail/issues/257) for details. Requires [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature to be enabled. - :package: **Offline access to the email messages** (attachments content not stored locally, but emails body content). The [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature enables storing your messages in the encrypted `database.bin` file (see [FAQ](https://github.com/vladimiry/ElectronMail/wiki/FAQ) for file purpose details). So the app allows you to view your messages offline, running full-text search against them, exporting them to EML files. etc. Enabled since [v2.0.0](https://github.com/vladimiry/ElectronMail/releases/tag/v2.0.0) release. - :mailbox: **Multi accounts** support including supporting individual [API entry points](https://github.com/vladimiry/ElectronMail/issues/29). For example, you can force the specific email account added in the app connect to the email provider via the [Tor](https://www.torproject.org/) only by selecting the `https://protonirockerxow.onion/` API entry point in the dropdown list and configuring a proxy as described in [this](https://github.com/vladimiry/ElectronMail/issues/113#issuecomment-529130116) message. - :unlock: **Automatic login into the app** with a remembered the system keychain remembered master ([keep me signed in](images/keep-me-signed-in.png) feature). Integration with as a system keychain is done with the [keytar](https://github.com/atom/node-keytar) module. By the way, on Linux [KeePassXC](https://github.com/keepassxreboot/keepassxc) implements the [Secret Service](https://specifications.freedesktop.org/secret-service/latest/) interface and so it can be acting as a system keychain (for details, see the "automatic login into the app"-related point in the [FAQ](https://github.com/vladimiry/ElectronMail/wiki/FAQ)). @@ -47,7 +48,7 @@ Some Linux package types are available for installing from the repositories (`Pa - :bell: **Native notifications** for individual accounts clicking on which focuses the app window and selects respective account in the accounts list. - :calendar: **Calendar notifications / alarms** regardless of the open page (mail/calendar/settings/account/drive). The opt-in feature has been enabled since [v4.9.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.9.0). See [#229](https://github.com/vladimiry/ElectronMail/issues/229) for details. - :sunglasses: **Making all email "read"** in a single mouse click. Enabled since [v3.8.0](https://github.com/vladimiry/ElectronMail/releases/tag/v3.8.0). Requires [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature to be enabled. -- :sunglasses: **Routing images through proxy**. The opt-in feature has been enabled since [v4.9.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.9.0). See [#312](https://github.com/vladimiry/ElectronMail/issues/312) for details. Requires [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature to be enabled. +- :sunglasses: **Routing images through proxy**. The opt-in feature has been enabled since [v4.9.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.9.0). See [#312](https://github.com/vladimiry/ElectronMail/issues/312) for details. - :sunglasses: **Batch mails removing** bypassing the trash. Enabled since [v4.9.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.9.0). Requires [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature to be enabled. - :sunglasses: **Batch mails moving between folders**. Enabled since [v4.5.0](https://github.com/vladimiry/ElectronMail/releases/tag/v4.5.0). Requires [local store](https://github.com/vladimiry/ElectronMail/wiki/FAQ) feature to be enabled. diff --git a/package.json b/package.json index 7d55bbc52..7fc5dfe06 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "electron-mail", "description": "Unofficial ProtonMail Desktop App", - "version": "4.10.3", + "version": "4.11.0", "author": "Vladimir Yakovlev ", "license": "MIT", "homepage": "https://github.com/vladimiry/ElectronMail", @@ -67,9 +67,9 @@ "assets:dev": "npm-run-all assets:copy:dev assets:webclient:dev", "assets:copy": "cpx \"./src/assets/dist/**/*\" ./app/assets", "assets:copy:dev": "cpx \"./src/assets/dist/**/*\" ./app-dev/assets", - "assets:webclient": "yarn assets:webclient:base ./app/webclient", - "assets:webclient:dev": "yarn assets:webclient:base ./app-dev/webclient", - "assets:webclient:base": "yarn ts-node:shortcut ./scripts/prepare-webclient/protonmail.ts", + "assets:webclient": "yarn assets:webclient:base ./app", + "assets:webclient:dev": "yarn assets:webclient:base ./app-dev", + "assets:webclient:base": "yarn ts-node:shortcut ./scripts/prepare-webclient/index.ts", "electron-builder:install-app-deps": "electron-builder install-app-deps --arch=x64", "electron-builder:dir": "electron-builder --x64 --dir", "electron-builder:dist": "npm exec --package=ts-node -- ts-node --files --require tsconfig-paths/register ./scripts/electron-builder/run-with-default-evn-vars.ts --x64 --publish never", @@ -101,7 +101,7 @@ "scripts/download-tray-icon-font": "yarn ts-node:shortcut ./scripts/download-tray-icon-font.ts", "scripts/transfer": "yarn ts-node:shortcut ./scripts/transfer/index.ts", "ts-node:shortcut": "cross-env TS_NODE_FILES=true ts-node --require tsconfig-paths/register", - "webpack:shortcut": "cross-env TS_NODE_FILES=true npm exec --package=webpack-cli --node-options=\"--require tsconfig-paths/register\" -- webpack" + "webpack:shortcut": "cross-env TS_NODE_PROJECT=./webpack-configs/tsconfig.json TS_NODE_FILES=true npm exec --package=webpack-cli --node-options=\"--require tsconfig-paths/register\" -- webpack" }, "ava": { "extensions": [ @@ -145,6 +145,7 @@ "proxy-agent": "4.0.1", "pure-uuid": "1.6.2", "pureimage": "0.2.7", + "quickjs-emscripten": "0.11.0", "reflect-metadata": "0.1.13", "remeda": "0.0.27", "rxjs": "6.6.6", @@ -212,6 +213,7 @@ "cpx2": "3.0.0", "cross-env": "7.0.3", "css-loader": "5.1.2", + "dts-generator": "3.0.0", "electron": "12.0.1", "electron-builder": "22.10.5", "escape-string-regexp": "4.0.0", @@ -227,6 +229,7 @@ "immer": "8.0.1", "import-sort-cli": "6.0.0", "import-sort-parser-typescript": "6.0.0", + "imports-loader": "2.0.0", "jasmine": "3.6.4", "jsdom": "16.5.1", "karma": "6.2.0", @@ -235,6 +238,7 @@ "karma-webpack": "5.0.0", "lint-staged": "10.5.4", "mini-css-extract-plugin": "1.3.9", + "monaco-editor": "0.23.0", "ndx": "1.0.2", "ndx-query": "1.0.1", "ngx-bootstrap": "6.2.0", diff --git a/scripts/lib.ts b/scripts/lib.ts index 8987cb76e..17a2284d1 100644 --- a/scripts/lib.ts +++ b/scripts/lib.ts @@ -223,3 +223,16 @@ export const catchTopLeventAsync = (asyncFn: () => Promise): void => { process.exit(1); }); }; + +export const applyPatch = async ({patchFile, cwd}: { patchFile: string; cwd: string }): Promise => { + await execShell([ + "git", + [ + "apply", + "--ignore-whitespace", + "--reject", + patchFile, + ], + {cwd}, + ]); +}; diff --git a/scripts/prepare-webclient/index.ts b/scripts/prepare-webclient/index.ts new file mode 100644 index 000000000..a44a67ce4 --- /dev/null +++ b/scripts/prepare-webclient/index.ts @@ -0,0 +1,22 @@ +import path from "path"; +import pathIsInside from "path-is-inside"; + +import {CWD_ABSOLUTE_DIR} from "scripts/const"; +import {buildProtonClients} from "scripts/prepare-webclient/protonmail"; +import {catchTopLeventAsync} from "scripts/lib"; +import {resolveProtonMetadata} from "scripts/prepare-webclient/monaco-editor-dts"; + +const [, , appDestDir] = process.argv; + +if (!appDestDir) { + throw new Error("Empty base destination directory argument"); +} + +if (!pathIsInside(path.resolve(CWD_ABSOLUTE_DIR, appDestDir), CWD_ABSOLUTE_DIR)) { + throw new Error(`Invalid base destination directory argument value: ${appDestDir}`); +} + +catchTopLeventAsync(async () => { + await buildProtonClients({destDir: path.join(appDestDir, "./webclient")}); + await resolveProtonMetadata({destDir: path.join(appDestDir)}); +}); diff --git a/scripts/prepare-webclient/monaco-editor-dts.ts b/scripts/prepare-webclient/monaco-editor-dts.ts new file mode 100644 index 000000000..b560b4560 --- /dev/null +++ b/scripts/prepare-webclient/monaco-editor-dts.ts @@ -0,0 +1,81 @@ +import fs from "fs"; +import fsExtra from "fs-extra"; +import path from "path"; + +import {CONSOLE_LOG} from "scripts/lib"; +import {IpcMainServiceScan} from "src/shared/api/main"; +import {PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION} from "src/shared/constants"; +import {PROTON_SHARED_MESSAGE_INTERFACE} from "src/shared/proton-apps-constants"; + +// TODO "require/var-requires"-based import +export const dtsGenerator: { // eslint-disable-line @typescript-eslint/no-unsafe-assignment + default: (options: { baseDir: string, files: string[], out: string }) => Promise +} = require("dts-generator"); // eslint-disable-line @typescript-eslint/no-var-requires + +export const resolveProtonMetadata = async ( + {destDir}: { destDir: string }, +): Promise => { + const options = { + system: { + base: "./node_modules/typescript/lib", + in: "./node_modules/typescript/lib/lib.esnext.d.ts", + out: path.join(destDir, PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION.system), + }, + protonMessage: { + base: "./output/git/proton-mail/node_modules/proton-shared", + in: path.join("./output/git/proton-mail/node_modules/proton-shared", PROTON_SHARED_MESSAGE_INTERFACE.projectRelativeFile), + out: path.join(destDir, PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION.protonMessage), + }, + } as const; + + // TODO replace "dts-generator" dependency with something capable to combine "./node_modules/typescript/lib/lib.esnext.d.ts" + for (const key of [/* "system", */ "protonMessage"] as const) { + const sourceFile = options[key].in; + if (!fsExtra.pathExistsSync(sourceFile)) { + throw new Error(`The source "${sourceFile}" file doesn't exits.`); + } + await dtsGenerator.default({baseDir: options[key].base, files: [sourceFile], out: options[key].out}); + CONSOLE_LOG(`Merged "${options[key].in}" to "${options[key].out}"`); + } + + // TODO drop custom "./node_modules/typescript/lib/lib.esnext.d.ts" combining when "dts-generator" gets replaced + { + const referenceTagRe = /\/\/\/[\s\t]+/; + const mergedFiles: string[] = []; + const extractContent = (file: string): string => { + const lines = fs.readFileSync(file).toString().split("\n"); + const resultLines: string[] = [`// === file: ${file}`]; + for (const line of lines) { + const match = referenceTagRe.exec(line); + const libName = match && match[1]; + if (libName) { + resultLines.push( + extractContent(path.join(path.dirname(file), `lib.${libName}.d.ts`)), + ); + } else { + resultLines.push(line); + } + } + mergedFiles.push(file); + return resultLines.join("\n"); + }; + fsExtra.ensureDirSync(path.dirname(options.system.out)); + fs.writeFileSync(options.system.out, extractContent(options.system.in)); + CONSOLE_LOG(`Merged to "${options.system.out}" files:`, mergedFiles); + } + + return { + system: [ + fs.readFileSync(options.system.out).toString(), + `in-memory:${options.system.in}`, + ], + protonMessage: [ + ( + fs.readFileSync(options.protonMessage.out).toString() + + + `declare const mail: Omit & {Body: string};` + ), + `in-memory:${PROTON_SHARED_MESSAGE_INTERFACE.url}`, + ], + }; +}; diff --git a/scripts/prepare-webclient/lib.ts b/scripts/prepare-webclient/protonmail-lib.ts similarity index 90% rename from scripts/prepare-webclient/lib.ts rename to scripts/prepare-webclient/protonmail-lib.ts index b347a94a1..7eb40aaf6 100644 --- a/scripts/prepare-webclient/lib.ts +++ b/scripts/prepare-webclient/protonmail-lib.ts @@ -1,10 +1,9 @@ import fs from "fs"; import fsExtra from "fs-extra"; import path from "path"; -import pathIsInside from "path-is-inside"; import {CONSOLE_LOG, execShell, resolveGitCommitInfo, resolveGitOutputBackupDir} from "scripts/lib"; -import {CWD_ABSOLUTE_DIR, GIT_CLONE_ABSOLUTE_DIR} from "scripts/const"; +import {GIT_CLONE_ABSOLUTE_DIR} from "scripts/const"; import {PROVIDER_REPO_MAP} from "src/shared/proton-apps-constants"; import {RUNTIME_ENV_CI_PROTON_CLIENTS_ONLY, WEB_CLIENTS_BLANK_HTML_FILE_NAME} from "src/shared/constants"; @@ -26,16 +25,6 @@ const reposOnlyFilter: DeepReadonly<{ value: Array { // eslint-disable-line @typescript-eslint/no-explicit-any folderNameAsDomain: string; options: T; @@ -75,18 +64,20 @@ export async function executeBuildFlow; - install?: Flow; - build: Flow; - }; + postClone?: Flow + install?: Flow + build: Flow + } }, ): Promise { if ( @@ -101,7 +92,7 @@ export async function executeBuildFlow => { if (fsExtra.pathExistsSync(path.resolve(repoDir, "node_modules"))) { CONSOLE_LOG("Skip dependencies installing"); } else if (flow.install) { @@ -170,6 +156,21 @@ export async function executeBuildFlow => { - await execShell([ - "git", - [ - "apply", - "--ignore-whitespace", - "--reject", - patchFile, - ], - {cwd}, - ]); -}; - -catchTopLeventAsync(async () => { +export const buildProtonClients = async ({destDir}: { destDir: string }): Promise => { for (const repoType of PROVIDER_REPO_NAMES) { await executeBuildFlow({ repoType, folderAsDomainEntries, + destDir, destSubFolder: PROVIDER_REPO_MAP[repoType].baseDirName, flow: { async install({repoDir}) { @@ -277,4 +265,4 @@ catchTopLeventAsync(async () => { }, }); } -}); +}; diff --git a/src/electron-main/api/endpoints-builders/database/search/api.ts b/src/electron-main/api/endpoints-builders/database/search/api.ts index 97147656c..0827fad20 100644 --- a/src/electron-main/api/endpoints-builders/database/search/api.ts +++ b/src/electron-main/api/endpoints-builders/database/search/api.ts @@ -1,14 +1,14 @@ import UUID from "pure-uuid"; import electronLog from "electron-log"; -import {Observable, of, race, throwError, timer} from "rxjs"; -import {concatMap, filter, first, mergeMap} from "rxjs/operators"; +import {Observable, from, of, race, throwError, timer} from "rxjs"; +import {concatMap, filter, first, mergeMap, switchMap} from "rxjs/operators"; import {Context} from "src/electron-main/model"; import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION$, IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$} from "src/electron-main/api/constants"; import {IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS, IPC_MAIN_API_DB_INDEXER_ON_ACTIONS, IpcMainApiEndpoints} from "src/shared/api/main"; -import {IndexableMailId, View} from "src/shared/model/database"; -import {curryFunctionMembers, walkConversationNodesTree} from "src/shared/util"; -import {searchRootConversationNodes} from "src/electron-main/api/endpoints-builders/database/search/service"; +import {IndexableMailId} from "src/shared/model/database"; +import {curryFunctionMembers} from "src/shared/util"; +import {searchRootConversationNodes, secondSearchStep} from "src/electron-main/api/endpoints-builders/database/search/service"; const logger = curryFunctionMembers(electronLog, "[src/electron-main/api/endpoints-builders/database/search/api]"); @@ -34,30 +34,21 @@ export async function buildDbSearchEndpoints( }, // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - async dbFullTextSearch({login, query, folderIds, hasAttachments, sentDateAfter}) { + async dbFullTextSearch({query, ...searchCriteria}) { logger.info("dbFullTextSearch()"); - const account = ctx.db.getAccount({login}); - - if (!account) { - throw new Error("Failed to resolve the account"); - } - - const searchUid: string | null = query + const fullTextSearchUid: string | null = query ? new UUID(4).format() : null; - const search$: Observable }>>> = query + const fullTextSearch$: Observable | null> = query ? race( IPC_MAIN_API_DB_INDEXER_ON_NOTIFICATION$.pipe( filter(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.is.SearchResult), - filter(({payload}) => payload.uid === searchUid), + filter(({payload}) => payload.uid === fullTextSearchUid), first(), - mergeMap(({payload: {data: {items}}}) => { - const mailScoresByPk = new Map( - items.map(({key, score}) => [key, score] as [IndexableMailId, number]), - ); - return [{mailScoresByPk}]; - }), + mergeMap(({payload: {data: {items}}}) => [new Map( + items.map(({key, score}) => [key, score] as [IndexableMailId, number]), + )]), ), await (async () => { const {timeouts: {fullTextSearch: timeoutMs}} = await ctx.config$.pipe(first()).toPromise(); @@ -65,88 +56,24 @@ export async function buildDbSearchEndpoints( concatMap(() => throwError(new Error(`Failed to complete the search in ${timeoutMs}ms`))), ); })(), - ) : of({}); - const result$ = search$.pipe( - mergeMap(({mailScoresByPk}) => { - const rootConversationNodes = searchRootConversationNodes( - account, - { - mailPks: mailScoresByPk - ? [...mailScoresByPk.keys()] - : undefined, - folderIds, - }, + ) + : of(null); + const result$ = fullTextSearch$.pipe( + switchMap((mailScoresByPk) => { + return from( + (async () => { + return { + mailsBundleItems: await secondSearchStep(ctx, searchCriteria, mailScoresByPk), + searched: Boolean(fullTextSearchUid), + }; + })(), ); - const filterByFolder = folderIds - ? ({id}: View.Folder): boolean => folderIds.includes(id) - : () => true; - const filterByHasAttachment = hasAttachments - ? (attachmentsCount: number): boolean => Boolean(attachmentsCount) - : () => true; - const filterBySentDateAfter = (() => { - const sentDateAfterFilterValue: number | null = sentDateAfter - ? new Date(String(sentDateAfter).trim()).getTime() - : null; - return sentDateAfterFilterValue - ? (sentDate: number): boolean => sentDate > sentDateAfterFilterValue - : () => true; - })(); - const getScore: (mail: Exclude) => number | undefined | null - = mailScoresByPk - ? ({pk}) => mailScoresByPk.get(pk) - : () => null; // no full-text search executing happened, so no score provided - const mailsBundleItems: Unpacked>["mailsBundleItems"] = []; - - for (const rootConversationNode of rootConversationNodes) { - let allNodeMailsCount = 0; - const matchedScoredNodeMails: Array["mail"]> = []; - - walkConversationNodesTree([rootConversationNode], ({mail}) => { - if (!mail) { - return; - } - - allNodeMailsCount++; - - const score = getScore(mail); - - if ( - ( - score === null // no full-text search executing happened, so accept all mails in this filter - || - typeof score === "number" - ) - && - mail.folders.find(filterByFolder) - && - filterByHasAttachment(mail.attachmentsCount) - && - filterBySentDateAfter(mail.sentDate) - ) { - matchedScoredNodeMails.push({...mail, score: score ?? undefined}); - } - }); - - if (!matchedScoredNodeMails.length) { - continue; - } - - mailsBundleItems.push( - ...matchedScoredNodeMails.map((mail) => ({mail, conversationSize: allNodeMailsCount})), - ); - } - - return [{mailsBundleItems, searched: Boolean(searchUid)}]; }), ); - if (searchUid) { + if (fullTextSearchUid) { IPC_MAIN_API_DB_INDEXER_NOTIFICATION$.next( - IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Search({ - query, - key: {login}, - uid: searchUid, - }), + IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS.Search({query, uid: fullTextSearchUid}), ); } diff --git a/src/electron-main/api/endpoints-builders/database/search/service.ts b/src/electron-main/api/endpoints-builders/database/search/service.ts index eb5529844..754c0d8d7 100644 --- a/src/electron-main/api/endpoints-builders/database/search/service.ts +++ b/src/electron-main/api/endpoints-builders/database/search/service.ts @@ -1,8 +1,13 @@ -import {Folder, FsDbAccount, Mail, View} from "src/shared/model/database"; +import {QuickJS, getQuickJS, shouldInterruptAfterDeadline} from "quickjs-emscripten"; + +import {Context} from "src/electron-main/model"; +import {Folder, FsDbAccount, IndexableMailId, LABEL_TYPE, Mail, View} from "src/shared/model/database"; +import {IpcMainApiEndpoints} from "src/shared/api/main"; +import {ONE_SECOND_MS} from "src/shared/constants"; import { buildFoldersAndRootNodePrototypes, fillFoldersAndReturnRootConversationNodes, - splitAndFormatAndFillSummaryFolders + splitAndFormatAndFillSummaryFolders, } from "src/electron-main/api/endpoints-builders/database/folders-view"; import {walkConversationNodesTree} from "src/shared/util"; @@ -48,3 +53,139 @@ export function searchRootConversationNodes( return result; } + +const formFoldersForQuickJSEvaluation = ( + folders: DeepReadonly>, + type: Unpacked, +): Array<{ Id: string, Name: string, Unread: number, Size: number }> => { + return folders + .filter((folder) => folder.type === type) + .map(({id, name, unread, size}) => ({Id: id, Name: name, Unread: unread, Size: size})); +}; + +const resolveQuickJS: () => Promise = (() => { + let getQuickJSPromise: ReturnType | undefined; + return async () => getQuickJSPromise ??= getQuickJS(); +})(); + +export const secondSearchStep = async ( + ctx: DeepReadonly, + {login, folderIds, hasAttachments, codeFilter, sentDateAfter}: + DeepReadonly[0], + "login" | "folderIds" | "hasAttachments" | "sentDateAfter" | "codeFilter">>, + mailScoresByPk: ReadonlyMap | null, +): Promise>["mailsBundleItems"]> => { + const account = ctx.db.getAccount({login}); + + if (!account) { + throw new Error("Failed to resolve the account"); + } + + const quickJS = await resolveQuickJS(); + const filters = { + byFolder: folderIds + ? ({id}: View.Folder): boolean => folderIds.includes(id) + : () => true, + byHasAttachment: hasAttachments + ? (attachmentsCount: number): boolean => Boolean(attachmentsCount) + : () => true, + byCode: codeFilter + ? ({pk, folders}: View.Mail): boolean => { + const mail = account.mails[pk]; + if (typeof mail === "undefined") { + throw new Error("Failed to resolve mail."); + } + const serializedMailCodePart = JSON.stringify( + JSON.stringify({ + ...JSON.parse(mail.raw), + Body: mail.body, + ...(mail.failedDownload && {_BodyDecryptionFailed : true}), + Folders: formFoldersForQuickJSEvaluation(folders, LABEL_TYPE.MESSAGE_FOLDER), + Labels: formFoldersForQuickJSEvaluation(folders, LABEL_TYPE.MESSAGE_LABEL), + }), + ); + return quickJS.evalCode(` + (() => { + let _result_ = false; + function filterMessage(filter) { + _result_ = filter( + JSON.parse(${serializedMailCodePart}), + ); + } + { + ${codeFilter} + } + return Boolean(_result_); + })() + `, + {shouldInterrupt: shouldInterruptAfterDeadline(Date.now() + ONE_SECOND_MS)}, + ) as boolean; + } + : () => true, + bySentDateAfter: (() => { + const sentDateAfterFilterValue: number | null = sentDateAfter + ? new Date(String(sentDateAfter).trim()).getTime() + : null; + return sentDateAfterFilterValue + ? (sentDate: number): boolean => sentDate > sentDateAfterFilterValue + : () => true; + })(), + } as const; + const getScore: (mail: Exclude) => number | undefined | null + = mailScoresByPk + ? ({pk}) => mailScoresByPk.get(pk) + : () => null; // no full-text search executing happened, so no score provided + const rootConversationNodes = searchRootConversationNodes( + account, + { + mailPks: mailScoresByPk + ? [...mailScoresByPk.keys()] + : undefined, + folderIds, + }, + ); + const mailsBundleItems: Unpacked> = []; + + for (const rootConversationNode of rootConversationNodes) { + let allNodeMailsCount = 0; + const matchedScoredNodeMails: Array["mail"]> = []; + + walkConversationNodesTree([rootConversationNode], ({mail}) => { + if (!mail) { + return; + } + + allNodeMailsCount++; + + const score = getScore(mail); + + if ( + ( + score === null // no full-text search executing happened, so accept all mails in this filter + || + typeof score === "number" + ) + && + mail.folders.find(filters.byFolder) + && + filters.byHasAttachment(mail.attachmentsCount) + && + filters.bySentDateAfter(mail.sentDate) + && + filters.byCode(mail) + ) { + matchedScoredNodeMails.push({...mail, score: score ?? undefined}); + } + }); + + if (!matchedScoredNodeMails.length) { + continue; + } + + mailsBundleItems.push( + ...matchedScoredNodeMails.map((mail) => ({mail, conversationSize: allNodeMailsCount})), + ); + } + + return mailsBundleItems; +}; diff --git a/src/electron-main/api/index.ts b/src/electron-main/api/index.ts index 61668559e..fdb2d17e0 100644 --- a/src/electron-main/api/index.ts +++ b/src/electron-main/api/index.ts @@ -1,4 +1,5 @@ import electronLog from "electron-log"; +import path from "path"; import {authenticator} from "otplib"; import {first} from "rxjs/operators"; @@ -6,9 +7,9 @@ import * as EndpointsBuilders from "./endpoints-builders"; import * as SpellCheck from "src/electron-main/spell-check/api"; import {Context} from "src/electron-main/model"; import {Database} from "src/electron-main/database"; -import {IPC_MAIN_API, IPC_MAIN_API_NOTIFICATION_ACTIONS, IpcMainApiEndpoints} from "src/shared/api/main"; +import {IPC_MAIN_API, IPC_MAIN_API_NOTIFICATION_ACTIONS, IpcMainApiEndpoints, IpcMainServiceScan} from "src/shared/api/main"; import {IPC_MAIN_API_NOTIFICATION$} from "src/electron-main/api/constants"; -import {PACKAGE_NAME, PRODUCT_NAME} from "src/shared/constants"; +import {PACKAGE_NAME, PRODUCT_NAME, PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION} from "src/shared/constants"; import {applyZoomFactor} from "src/electron-main/window/util"; import {attachFullTextIndexWindow, detachFullTextIndexWindow} from "src/electron-main/window/full-text-search"; import {buildSettingsAdapter} from "src/electron-main/util"; @@ -56,8 +57,24 @@ export const initApi = async (ctx: Context): Promise => { // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async staticInit() { + const fsPromise = await import("fs/promises"); + const monacoEditorExtraLibArgs: IpcMainServiceScan["ApiImplReturns"]["staticInit"]["monacoEditorExtraLibArgs"] + = {system: [""], protonMessage: [""]}; + + for (const key of Object.keys(monacoEditorExtraLibArgs) as Array) { + // TODO read file once (cache the content) + const fileContent = await fsPromise.readFile( + path.join(ctx.locations.appDir, PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION[key]), + ); + monacoEditorExtraLibArgs[key] = [ + fileContent.toString(), + PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION[key], + ]; + } + return { electronLocations: ctx.locations, + monacoEditorExtraLibArgs, }; }, diff --git a/src/electron-preload/database-indexer/index.ts b/src/electron-preload/database-indexer/index.ts index be42f698e..c43b748e5 100644 --- a/src/electron-preload/database-indexer/index.ts +++ b/src/electron-preload/database-indexer/index.ts @@ -35,31 +35,27 @@ async function dbIndexerNotificationHandler( await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {indexing: true}})); - await indexingQueue.q(async () => { - removeMailsFromIndex(index, remove); - addToMailsIndex(index, add); - }); + try { + await indexingQueue.q(async () => { + removeMailsFromIndex(index, remove); + addToMailsIndex(index, add); + }); + } finally { + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {indexing: false}})); + } - await Promise.all([ - dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {indexing: false}})), - dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.IndexingResult({uid})), - ]); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.IndexingResult({uid})); return emptyObject; }, - Search: async ({key, uid, query}) => { + Search: async ({uid, query}) => { logger.info("action.Search()"); - await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {searching: true}})); - const {items, expandedTerms} = await indexingQueue.q(async () => { return index.search(query); }); - await Promise.all([ - dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.ProgressState({key, status: {searching: false}})), - dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.SearchResult({uid, data: {items, expandedTerms}})), - ]); + await dbIndexerOn(IPC_MAIN_API_DB_INDEXER_ON_ACTIONS.SearchResult({uid, data: {items, expandedTerms}})); return emptyObject; }, diff --git a/src/shared/api/main.ts b/src/shared/api/main.ts index 71b7c800a..ecdd92fb9 100644 --- a/src/shared/api/main.ts +++ b/src/shared/api/main.ts @@ -23,10 +23,7 @@ export const IPC_MAIN_API_DB_INDEXER_ON_ACTIONS = unionize({ Bootstrapped: ofType>(), ProgressState: ofType<{ key: DbModel.DbAccountPk; - status: { - indexing?: boolean; - searching?: boolean; - }; + status: { indexing?: boolean }; } | { status: { indexing?: boolean; @@ -57,11 +54,7 @@ export const IPC_MAIN_API_DB_INDEXER_NOTIFICATION_ACTIONS = unionize({ add: DbModel.IndexableMail[]; uid: string; }>(), - Search: ofType<{ - key: DbModel.DbAccountPk; - query: string; - uid: string; - }>(), + Search: ofType<{ query: string, uid: string }>(), }, { tag: "type", @@ -177,6 +170,7 @@ export const ENDPOINTS_DEFINITION = { sentDateAfter: string; hasAttachments: boolean; folderIds?: Array; + codeFilter?: string; }>, NoExtraProps<{ searched: boolean; mailsBundleItems: Array<{ mail: DbModel.View.Mail & { score?: number }; conversationSize: number }>; @@ -186,9 +180,11 @@ export const ENDPOINTS_DEFINITION = { dbIndexerNotification: ActionType.Observable>(), - staticInit: ActionType.Promise>(), + staticInit: ActionType.Promise> + }>(), init: ActionType.Promise(), diff --git a/src/shared/constants.ts b/src/shared/constants.ts index ff28b6e41..d3f6f17ea 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -6,7 +6,7 @@ import { name as PACKAGE_NAME, version as PACKAGE_VERSION, } from "package.json"; -import {PROVIDER_REPO_MAP} from "src/shared/proton-apps-constants"; +import {PROTON_SHARED_MESSAGE_INTERFACE, PROVIDER_REPO_MAP} from "src/shared/proton-apps-constants"; export const PRODUCT_NAME = "ElectronMail"; @@ -46,6 +46,14 @@ export const WEB_CHUNK_NAMES = { "search-in-page-browser-view": "search-in-page-browser-view", } as const; +export const PROTON_MONACO_EDITOR_DTS_ASSETS_LOCATION = { + // TODO "electron-builder" doesn't pack the resources with "node_modules" folder in the path, so renamed to "node_modules_" for now + system: + "./assets/db-search-monaco-editor/node_modules_/typescript/lib/lib.esnext.d.ts", + protonMessage: + `./assets/db-search-monaco-editor/proton-shared/${PROTON_SHARED_MESSAGE_INTERFACE.projectRelativeFile.replace(".ts", ".d.ts")}`, +} as const; + export const LOCAL_WEBCLIENT_PROTOCOL_PREFIX = "webclient"; export const LOCAL_WEBCLIENT_PROTOCOL_RE_PATTERN = `${LOCAL_WEBCLIENT_PROTOCOL_PREFIX}[\\d]+`; diff --git a/src/shared/proton-apps-constants.ts b/src/shared/proton-apps-constants.ts index 844c07bef..2437025cd 100644 --- a/src/shared/proton-apps-constants.ts +++ b/src/shared/proton-apps-constants.ts @@ -83,3 +83,8 @@ export const PROVIDER_REPO_MAP = { protonPack: {appConfig: {clientId: "WebDrive"}}, }, } as const; + +export const PROTON_SHARED_MESSAGE_INTERFACE = { + projectRelativeFile: "./lib/interfaces/mail/Message.ts", + url: "", +} as const; diff --git a/src/web/browser-window/@types/index.d.ts b/src/web/browser-window/@types/index.d.ts index cef742bd5..6fab3773a 100644 --- a/src/web/browser-window/@types/index.d.ts +++ b/src/web/browser-window/@types/index.d.ts @@ -1,7 +1,5 @@ import {IpcMainServiceScan} from "src/shared/api/main"; declare global { - const BUILD_ANGULAR_COMPILATION_FLAGS: import("webpack-configs/model").BuildAngularCompilationFlags; - const __METADATA__: DeepReadonly; } diff --git a/src/web/browser-window/app/_db-view/db-view-mail-body.component.html b/src/web/browser-window/app/_db-view/db-view-mail-body.component.html index 553fb5bcf..4f29a6635 100644 --- a/src/web/browser-window/app/_db-view/db-view-mail-body.component.html +++ b/src/web/browser-window/app/_db-view/db-view-mail-body.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/web/browser-window/app/_db-view/db-view-mail-body.component.ts b/src/web/browser-window/app/_db-view/db-view-mail-body.component.ts index 34fc2897c..72c424d74 100644 --- a/src/web/browser-window/app/_db-view/db-view-mail-body.component.ts +++ b/src/web/browser-window/app/_db-view/db-view-mail-body.component.ts @@ -35,7 +35,7 @@ export class DbViewMailBodyComponent extends DbViewAbstractComponent implements @Input() selectedFolderData?: Instance["selectedFolderData"]; - selectedMail$ = this.instance$.pipe( + selectedMailToggled$ = this.instance$.pipe( map((value) => value.selectedMail), mergeMap((value) => value ? [value] : EMPTY), distinctUntilChanged((prev, curr) => ( @@ -116,7 +116,7 @@ export class DbViewMailBodyComponent extends DbViewAbstractComponent implements ); this.subscription.add( - this.selectedMail$.subscribe((selectedMail) => { + this.selectedMailToggled$.subscribe((selectedMail) => { this.renderBody(selectedMail.conversationMail); }), ); @@ -126,7 +126,7 @@ export class DbViewMailBodyComponent extends DbViewAbstractComponent implements this.conversationCollapsed$.pipe( distinctUntilChanged(), ), - this.selectedMail$.pipe( + this.selectedMailToggled$.pipe( map((value) => value.rootNode), distinctUntilChanged(), ), @@ -170,7 +170,7 @@ export class DbViewMailBodyComponent extends DbViewAbstractComponent implements this.store.dispatch(ACCOUNTS_ACTIONS.ToggleDatabaseView({login: this.webAccountPk.login, forced: {databaseView: false}})); }); - this.selectedMail$ + this.selectedMailToggled$ .pipe(first()) .subscribe(({conversationMail: {id, mailFolderIds, conversationEntryPk}}) => { this.store.dispatch(ACCOUNTS_ACTIONS.SelectMailOnline({ @@ -184,7 +184,7 @@ export class DbViewMailBodyComponent extends DbViewAbstractComponent implements reDownload(): void { this.webAccountPk$.pipe( withLatestFrom( - this.selectedMail$.pipe( + this.selectedMailToggled$.pipe( map((selectedMail) => selectedMail.conversationMail), ), ), diff --git a/src/web/browser-window/app/_db-view/db-view-mails-search.component.html b/src/web/browser-window/app/_db-view/db-view-mails-search.component.html index a508b8f44..eb5d6dad3 100644 --- a/src/web/browser-window/app/_db-view/db-view-mails-search.component.html +++ b/src/web/browser-window/app/_db-view/db-view-mails-search.component.html @@ -1,108 +1,134 @@
- -
+
-
- -
diff --git a/src/web/browser-window/app/_db-view/db-view-mails-search.component.scss b/src/web/browser-window/app/_db-view/db-view-mails-search.component.scss index 58f35b432..94a82f85d 100644 --- a/src/web/browser-window/app/_db-view/db-view-mails-search.component.scss +++ b/src/web/browser-window/app/_db-view/db-view-mails-search.component.scss @@ -5,6 +5,14 @@ flex-direction: row; flex-grow: 1; + .btn-warning-light { + $bg: map_get($app-account-title-btn-colors, "selected-bg"); + $border: map_get($app-account-title-btn-colors, "selected-border"); + // second pair of bg/border passed to suppress "hover" state + // third pair of bg/border passed to suppress "active" state + @include button-variant($bg, $border, $bg, $border, $bg, $border); + } + .custom-switch { @include font-size($input-font-size-sm); diff --git a/src/web/browser-window/app/_db-view/db-view-mails-search.component.ts b/src/web/browser-window/app/_db-view/db-view-mails-search.component.ts index f84a18f2b..049b00f65 100644 --- a/src/web/browser-window/app/_db-view/db-view-mails-search.component.ts +++ b/src/web/browser-window/app/_db-view/db-view-mails-search.component.ts @@ -12,7 +12,7 @@ import { import {EMPTY, Observable, Subject, combineLatest, merge} from "rxjs"; import {FormControl, FormGroup} from "@angular/forms"; import {Store, select} from "@ngrx/store"; -import {distinctUntilChanged, map, mergeMap, switchMap, takeUntil, tap} from "rxjs/operators"; +import {distinctUntilChanged, map, mergeMap, switchMap, takeUntil, tap,} from "rxjs/operators"; import {AccountsSelectors} from "src/web/browser-window/app/store/selectors"; import {DB_VIEW_ACTIONS} from "src/web/browser-window/app/store/actions"; @@ -47,7 +47,7 @@ export class DbViewMailsSearchComponent extends DbViewAbstractComponent implemen }), ); - @ViewChildren("queryFormControl") + @ViewChildren("queryFormControlRef") readonly queryFormControlRefs!: QueryList; readonly formControls = { @@ -56,7 +56,7 @@ export class DbViewMailsSearchComponent extends DbViewAbstractComponent implemen allFoldersToggled: new FormControl(false), sentDateAfter: new FormControl(), hasAttachments: new FormControl(false), - }; + } as const; readonly form = new FormGroup(this.formControls); @@ -65,6 +65,7 @@ export class DbViewMailsSearchComponent extends DbViewAbstractComponent implemen readonly selectedMail$ = this.instance$.pipe( map((value) => value.selectedMail), + distinctUntilChanged((prev, curr) => curr?.listMailPk === prev?.listMailPk), ); readonly accountProgress$ = this.account$.pipe( @@ -109,8 +110,12 @@ export class DbViewMailsSearchComponent extends DbViewAbstractComponent implemen SYSTEM_FOLDER_IDENTIFIERS.Spam, ]); + codeEditorOpen?: boolean; + + codeFilter?: string; + constructor( - store: Store, + readonly store: Store, ) { super(store); } @@ -172,6 +177,10 @@ export class DbViewMailsSearchComponent extends DbViewAbstractComponent implemen this.backToListHandler.emit(); } + onEditorContentChange({codeEditorContent}: { codeEditorContent?: string }): void { + this.codeFilter = codeEditorContent; + } + submit(): void { this.store.dispatch( DB_VIEW_ACTIONS.FullTextSearchRequest({ @@ -180,6 +189,7 @@ export class DbViewMailsSearchComponent extends DbViewAbstractComponent implemen sentDateAfter: this.formControls.sentDateAfter.value, // eslint-disable-line @typescript-eslint/no-unsafe-assignment hasAttachments: this.formControls.hasAttachments.value, // eslint-disable-line @typescript-eslint/no-unsafe-assignment folderIds: this.resolveSelectedIds(), + ...(this.codeEditorOpen && {codeFilter: this.codeFilter}), }), ); } diff --git a/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.html b/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.html new file mode 100644 index 000000000..7db4cf0f0 --- /dev/null +++ b/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.html @@ -0,0 +1,23 @@ +
+
+ + +
+
+
diff --git a/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.scss b/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.scss new file mode 100644 index 000000000..10c346eab --- /dev/null +++ b/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.scss @@ -0,0 +1,10 @@ +@import "~src/web/variables"; + +:host { + display: flex; + flex-direction: column; + + .monaco-editor > .overflow-guard { + border: 1px solid $app-db-view-color-border; + } +} diff --git a/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.ts b/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.ts new file mode 100644 index 000000000..3b1dae331 --- /dev/null +++ b/src/web/browser-window/app/_db-view/db-view-monaco-editor.component.ts @@ -0,0 +1,296 @@ +import {ChangeDetectionStrategy, Component, ElementRef, EventEmitter, NgZone, OnInit, Output} from "@angular/core"; +import {Store} from "@ngrx/store"; +import {debounceTime, distinctUntilChanged, filter, map, pairwise, takeUntil, withLatestFrom,} from "rxjs/operators"; +import {fromEvent, merge} from "rxjs"; +import {noop} from "remeda"; + +import * as monaco from "monaco-editor"; +import {DbViewAbstractComponent} from "src/web/browser-window/app/_db-view/db-view-abstract.component"; +import {LABEL_TYPE, View} from "src/shared/model/database"; +import {ONE_SECOND_MS} from "src/shared/constants"; +import {State} from "src/web/browser-window/app/store/reducers/db-view"; + +// TODO turn the hardcoded code samples library into the user-editable list of snippets +const codeSnippets = ([ + { + title: "Filter by body content", + value: ` + // try to export some messages to JSON to see the data model structure + filterMessage((mail) => { + return mail.Body.toLowerCase().includes("thank you") + }) + `, + }, + { + title: "Filter by header value", + value: ` + filterMessage(({ParsedHeaders}) => { + return Object + .entries(ParsedHeaders) + .some(([name, value]) => name.toLowerCase() === "content-type" && value === "text/plain") + }) + `, + }, + { + title: "Filter by sender domain and attachment size", + value: ` + const twoMegabytes = 1024 * 1204 * 2; + const domains = ["@protonmail.com", "@protonmail.zendesk.com"] + filterMessage((mail) => ( + domains.some((domain) => mail.Sender.Address.endsWith(domain)) + && // and + mail.Attachments.some(({Size}) => Size > twoMegabytes) + )) + `, + }, +] as const).map(({title, value}) => { + // TODO remove temporary code that remove starting spaces from the snippets + const lines = value.split("\n").filter((line) => Boolean(line.trim())); + const skipCharsFromStart = lines.reduce((value, line) => Math.min(line.length - line.trimLeft().length, value), Number.MAX_VALUE); + return {title, value: lines.map((line) => line.substr(skipCharsFromStart)).join("\n"), disabled: true}; +}); + +@Component({ + selector: "electron-mail-db-view-monaco-editor", + templateUrl: "./db-view-monaco-editor.component.html", + styleUrls: ["./db-view-monaco-editor.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DbViewMonacoEditorComponent extends DbViewAbstractComponent implements OnInit { + @Output() + readonly content = new EventEmitter<{ codeEditorContent?: string }>(); + + readonly folders$ = this.instance$.pipe( + map((value) => [...value.folders.system, ...value.folders.custom]), + ); + + readonly selectedMail$ = this.instance$.pipe( + map((value) => value.selectedMail), + distinctUntilChanged((prev, curr) => curr?.listMailPk === prev?.listMailPk), + ); + + readonly codeSnippets = codeSnippets; + + editorInstance?: ReturnType; + + constructor( + readonly store: Store, + private readonly zone: NgZone, + private readonly elementRef: ElementRef, + ) { + super(store); + } + + ngOnInit(): void { + this.folders$ + .pipe(takeUntil(this.ngOnDestroy$)) + .subscribe((folders) => this.initializeEditor(folders)); + + this.selectedMail$ + .pipe( + pairwise(), + filter(([prev, curr]) => Boolean(prev) !== Boolean(curr)), + takeUntil(this.ngOnDestroy$), + ) + .subscribe(() => { + // TODO implement proper "monaco editor" area layouting (dynamic "width" at least) + this.editorInstance?.layout(); + }); + + // TODO drop "unhandledrejection" event handling when the following fix gets released: + // https://github.com/microsoft/vscode/commit/49cad9a1c0d9ef01d66eef60b261c7ebcffcef23 + fromEvent(window, "unhandledrejection") + .pipe(takeUntil(this.ngOnDestroy$)) + .subscribe((event) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {stack = "", name}: { stack?: string, name?: string } = event?.reason ?? {}; + if ( + name === "Canceled" + && + ["Delayer.cancel", "Function.onMouseLeave"].every((stackToInclude) => stack.includes(stackToInclude))) { + event.preventDefault(); + } + }); + + this.ngOnDestroy$.subscribe(() => { + this.editorInstance?.dispose(); + delete this.editorInstance; + }); + } + + private updateMonacoEditorWidth( + selectedMail: Unpacked, + ): void { + const editor = this.editorInstance; + + if (!editor) { + return; + } + + // TODO remove 8px use from the code logic (outer h-padding: 8px in CSS) + const componentHorizontalPadding = 8; + const widthDelimiter = 1 + Number(Boolean(selectedMail)); + // TODO don't calculate sizing based on window size but parent component size + // TODO improve width calculation if window width value is lower than "map-get($grid-breakpoints, lg) / 1200px" value + const windowBasedWidth = window.innerWidth / widthDelimiter - componentHorizontalPadding * 2; + + try { + editor.layout({ + width: windowBasedWidth + ((widthDelimiter - 1) * 2), + height: editor.getLayoutInfo().height, + }); + } finally {} // eslint-disable-line no-empty + + this.updateMonacoEditorHeight(); + } + + private updateMonacoEditorHeight(): void { + const editor = this.editorInstance; + + if (!editor) { + return; + } + + const maxHeight = 300; + const {width} = editor.getLayoutInfo(); + const contentHeight = Math.min(maxHeight, editor.getContentHeight()); + + Object.assign( + editor.getContainerDomNode().style, + {width: `${width}px`, height: `${contentHeight}px`}, + ); + + try { + editor.layout({width, height: contentHeight}); + } finally {} // eslint-disable-line no-empty + } + + private initializeEditor(folders: DeepReadonly): void { + const folderDtsCodeInclude = (type: Unpacked): string => ` + { ${type === LABEL_TYPE.MESSAGE_FOLDER ? "Folders" : "Labels"}: Array<{ + Id: string, Unread: number, Size: number + Name: ${folders.filter((folder) => folder.type === type).map(({name}) => JSON.stringify(name)).join(" | ")} + }> } + `; + + monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ + allowNonTsExtensions: true, + target: monaco.languages.typescript.ScriptTarget.ESNext, + }); + + monaco.languages.typescript.typescriptDefaults.setExtraLibs( + (["system", "protonMessage"] as const).map((key) => { + const footerContent = key === "protonMessage" + ? (() => { + return ` + type Primitive = string | number | boolean | bigint | symbol | undefined | null; + type Builtin = Primitive | Function | Date | Error | RegExp; + type DeepReadonly = T extends Builtin + ? T + : T extends Map + ? ReadonlyMap, DeepReadonly> + : T extends ReadonlyMap + ? ReadonlyMap, DeepReadonly> + : T extends WeakMap + ? WeakMap, DeepReadonly> + : T extends Set + ? ReadonlySet> + : T extends ReadonlySet + ? ReadonlySet> + : T extends WeakSet + ? WeakSet> + : T extends Promise + ? Promise> + : T extends {} + ? { readonly [K in keyof T]: DeepReadonly } + : Readonly; + declare const filterMessage = ( + filter: ( + mail: DeepReadonly< + Omit + & + { Body: string, _BodyDecryptionFailed?: boolean } + & + ${folderDtsCodeInclude(LABEL_TYPE.MESSAGE_FOLDER)} + & + ${folderDtsCodeInclude(LABEL_TYPE.MESSAGE_LABEL)} + > + ) => boolean, + ) => void; + `; + })() + : ""; + const [content, filePath] = __METADATA__.monacoEditorExtraLibArgs[key]; + + return {content: content + footerContent, filePath}; + }), + ); + + this.zone.runOutsideAngular(this.initializeEditorInstance.bind(this)); + } + + private initializeEditorInstance(): void { + // making sure function called once + this.initializeEditorInstance = noop; + + this.editorInstance = monaco.editor.create( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + this.elementRef.nativeElement.querySelector(".editor-block") as HTMLElement, + { + value: this.codeSnippets[0]?.value, + language: "typescript", + codeLens: false, + contextmenu: false, + dragAndDrop: false, + folding: false, + glyphMargin: false, + lineDecorationsWidth: 5, + lineNumbers: "off", + lineNumbersMinChars: 0, + minimap: {enabled: false}, + overviewRulerLanes: 0, + padding: {top: 5, bottom: 5}, + scrollbar: {horizontal: "auto", vertical: "auto"}, + scrollBeyondLastLine: false, + // automaticLayout: true, // doesn't work well enough + }, + ); + + // tweak layout: width + this.editorInstance.dispose = ((originalMmonacoEditorDisposeFn, updateMonacoEditorWidthSubscription) => { + return () => { + updateMonacoEditorWidthSubscription.unsubscribe(); + originalMmonacoEditorDisposeFn(); + }; + })( + this.editorInstance.dispose.bind(this.editorInstance), + merge( + fromEvent(window, "resize").pipe( + debounceTime(ONE_SECOND_MS / 5), + ).pipe( + withLatestFrom(this.selectedMail$), + map(([, selectedMail]) => selectedMail), + ), + this.selectedMail$, + ).pipe( + takeUntil(this.ngOnDestroy$), + ).subscribe(this.updateMonacoEditorWidth.bind(this)), + ); + + // tweak layout: height + this.editorInstance.onDidContentSizeChange(this.updateMonacoEditorHeight.bind(this)); + this.updateMonacoEditorHeight(); + + this.editorInstance.onDidChangeModelContent(this.propagateEditorContent.bind(this)); + this.propagateEditorContent(); + + setTimeout(() => { + this.editorInstance?.setSelection(new monaco.Selection(1, 2, 1, 2)); + this.editorInstance?.focus(); + }, ONE_SECOND_MS / 4); + } + + private propagateEditorContent(): void { + this.content.emit({codeEditorContent: this.editorInstance?.getValue()}); + } +} diff --git a/src/web/browser-window/app/_db-view/db-view.effects.ts b/src/web/browser-window/app/_db-view/db-view.effects.ts index 60308097c..132b331cb 100644 --- a/src/web/browser-window/app/_db-view/db-view.effects.ts +++ b/src/web/browser-window/app/_db-view/db-view.effects.ts @@ -4,7 +4,7 @@ import {EMPTY, forkJoin, from, merge, of} from "rxjs"; import {Injectable, NgZone} from "@angular/core"; import {Store, select} from "@ngrx/store"; import {concatMap, filter, finalize, map, mergeMap, switchMap, takeUntil, tap, throttleTime, withLatestFrom} from "rxjs/operators"; -import {pick} from "remeda"; +import {omit, pick} from "remeda"; import {ACCOUNTS_ACTIONS, DB_VIEW_ACTIONS, OPTIONS_ACTIONS, unionizeActionFilter,} from "src/web/browser-window/app/store/actions"; import {ElectronService} from "src/web/browser-window/app/_core/electron.service"; @@ -157,27 +157,32 @@ export class DbViewEffects { () => this.actions$.pipe( unionizeActionFilter(DB_VIEW_ACTIONS.is.FullTextSearchRequest), withLatestFrom(this.store.pipe(select(OptionsSelectors.CONFIG.timeouts))), - mergeMap(( - [{payload: {login, query, folderIds, sentDateAfter, hasAttachments, accountIndex}}, - {fullTextSearch: fullTextSearchTimeoutMs}], - ) => { - const webAccountPk = {login, accountIndex} as const; - const dbFullTextSearch$ = from( - this.api.ipcMainClient()( - "dbFullTextSearch", - { - // "fullTextSearchTimeoutMs" is the full-text search specific value - // so adding 20% reserve for result serialization/etc - timeoutMs: fullTextSearchTimeoutMs * 1.2, - serialization: "jsan", - }, - )({login, query, folderIds, sentDateAfter, hasAttachments}), - ); - return dbFullTextSearch$.pipe( - mergeMap((value) => [ - DB_VIEW_ACTIONS.SelectMail({webAccountPk}), - DB_VIEW_ACTIONS.FullTextSearch({webAccountPk, value}), - ]), + mergeMap(([{payload}, {fullTextSearch: fullTextSearchTimeoutMs}]) => { + const webAccountPk = pick(payload, ["login", "accountIndex"]); + return merge( + of(ACCOUNTS_ACTIONS.PatchProgress({login: payload.login, patch: {searching: true}})), + from( + this.api.ipcMainClient()( + "dbFullTextSearch", + { + // "fullTextSearchTimeoutMs" is the full-text search only specific value + // so adding reserve for "second step of the search" (by folders/data/js-code/etc), result serialization/etc + // TODO introduce addition timeout for the "second step of the search" + timeoutMs: payload.codeFilter + ? fullTextSearchTimeoutMs * 5 + : fullTextSearchTimeoutMs * 1.2, + serialization: "jsan", + }, + )(omit(payload, ["accountIndex"])), + ).pipe( + mergeMap((value) => [ + DB_VIEW_ACTIONS.SelectMail({webAccountPk}), + DB_VIEW_ACTIONS.FullTextSearch({webAccountPk, value}), + ]), + finalize(() => { + this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login: payload.login, patch: {searching: false}})); + }), + ), ); }), ), diff --git a/src/web/browser-window/app/_db-view/db-view.module.ts b/src/web/browser-window/app/_db-view/db-view.module.ts index efa12a9a2..8bbef59b6 100644 --- a/src/web/browser-window/app/_db-view/db-view.module.ts +++ b/src/web/browser-window/app/_db-view/db-view.module.ts @@ -16,6 +16,7 @@ import {DbViewMailTabComponent} from "./db-view-mail-tab.component"; import {DbViewMailsComponent} from "./db-view-mails.component"; import {DbViewMailsExportComponent} from "./db-view-mails-export.component"; import {DbViewMailsSearchComponent} from "./db-view-mails-search.component"; +import {DbViewMonacoEditorComponent} from "./db-view-monaco-editor.component"; import {SharedModule} from "src/web/browser-window/app/_shared/shared.module"; @NgModule({ @@ -37,6 +38,7 @@ import {SharedModule} from "src/web/browser-window/app/_shared/shared.module"; DbViewMailsExportComponent, DbViewMailsSearchComponent, DbViewMailTabComponent, + DbViewMonacoEditorComponent, ], entryComponents: [ DbViewEntryComponent, diff --git a/src/web/browser-window/app/_options/account-edit.component.html b/src/web/browser-window/app/_options/account-edit.component.html index c8f82dc4f..71dd958e9 100644 --- a/src/web/browser-window/app/_options/account-edit.component.html +++ b/src/web/browser-window/app/_options/account-edit.component.html @@ -48,7 +48,7 @@
diff --git a/src/web/browser-window/app/_options/base-settings.component.html b/src/web/browser-window/app/_options/base-settings.component.html index 0bfc31dfd..ae74b8c4c 100644 --- a/src/web/browser-window/app/_options/base-settings.component.html +++ b/src/web/browser-window/app/_options/base-settings.component.html @@ -229,6 +229,7 @@
diff --git a/src/web/browser-window/index.scss b/src/web/browser-window/index.scss index 3bae0175f..768d5e008 100644 --- a/src/web/browser-window/index.scss +++ b/src/web/browser-window/index.scss @@ -127,3 +127,11 @@ ng-select.ng-invalid.ng-touched .ng-select-container { border-color: #dc3545; box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 0 3px #fde6e8; } + +// "monaco-editor" +.monaco-hover-content .hover-row.status-bar .actions { + // hiding "View Problem (Alt+F8)" action + .action-container:nth-of-type(1) { + display: none !important; + } +} diff --git a/src/web/browser-window/vendor/electron-webview-angular-fix.ts b/src/web/browser-window/vendor/electron-webview-angular-fix.ts deleted file mode 100644 index 45ac45721..000000000 --- a/src/web/browser-window/vendor/electron-webview-angular-fix.ts +++ /dev/null @@ -1,36 +0,0 @@ -// https://github.com/electron/electron/issues/10176 - -const originalRemoveEventListener = window.removeEventListener; - -function needToCallOriginalMethod(name: string, listenerFunctionStringified: string): boolean { - return ( - name === "readystatechange" - && - listenerFunctionStringified.includes("registerBrowserPluginElement()") - && - listenerFunctionStringified.includes("registerWebViewElement()") - ); -} - -function removeEventListenerOverloaded( - this: any, // eslint-disable-line @typescript-eslint/no-explicit-any - ...args: [string /* name */, EventListenerOrEventListenerObject, ...any[]] // eslint-disable-line @typescript-eslint/no-explicit-any -): void { - const [name, listener] = args; - - if (listener && needToCallOriginalMethod(name, listener.toString())) { - // calling native/original implementation - return originalRemoveEventListener.apply( - this, - args as Parameters, - ); - } - - // calling implementation patched by Zone.js - return EventTarget.prototype.removeEventListener.apply( - this, - args as Parameters, - ); -} - -window.removeEventListener = removeEventListenerOverloaded; diff --git a/src/web/browser-window/vendor/index.ts b/src/web/browser-window/vendor/index.ts index c2e422d78..995ef35aa 100644 --- a/src/web/browser-window/vendor/index.ts +++ b/src/web/browser-window/vendor/index.ts @@ -2,4 +2,3 @@ import "zone.js/dist/zone"; import "./shared-vendor.scss"; import "./app.scss"; -import "./electron-webview-angular-fix.ts"; diff --git a/webpack-configs/model.ts b/webpack-configs/model.ts index c05afb747..f167cd9a4 100644 --- a/webpack-configs/model.ts +++ b/webpack-configs/model.ts @@ -1,6 +1,3 @@ export type BuildEnvironment = "production" | "development" | "test" | "e2e"; -export type BuildAngularCompilationFlags = Readonly<{ - aot: boolean; - ivy: boolean; -}>; +export type BuildAngularCompilationFlags = { readonly aot: boolean, readonly ivy: boolean }; diff --git a/webpack-configs/web/browser-window.ts b/webpack-configs/web/browser-window.ts index 5f9116a3b..68c2769e3 100644 --- a/webpack-configs/web/browser-window.ts +++ b/webpack-configs/web/browser-window.ts @@ -1,5 +1,4 @@ import {AngularCompilerPlugin, NgToolsLoader, PLATFORM} from "@ngtools/webpack"; -import {DefinePlugin} from "webpack"; import {readConfiguration} from "@angular/compiler-cli"; import {BuildAngularCompilationFlags, BuildEnvironment} from "webpack-configs/model"; @@ -7,10 +6,7 @@ import {ENVIRONMENT, rootRelativePath} from "webpack-configs/lib"; import {WEB_CHUNK_NAMES} from "src/shared/constants"; import {browserWindowAppPath, browserWindowPath, buildBaseWebConfig, cssRuleSetRules} from "./lib"; -const angularCompilationFlags: BuildAngularCompilationFlags = { - aot: true, - ivy: true, -}; +const angularCompilationFlags: BuildAngularCompilationFlags = {aot: true, ivy: true}; const tsConfigFile = browserWindowPath(({ production: "./tsconfig.json", @@ -50,6 +46,37 @@ const config = buildBaseWebConfig( browserWindowAppPath(), ], }, + { + test: require.resolve("monaco-editor/esm/vs/base/common/platform.js"), + use: [ + { + loader: "imports-loader", + options: { + additionalCode: ` + const self = { + MonacoEnvironment: { + getWorkerUrl(...[, label]) { + if (label === "json") { + return "./json.worker.js"; + } + if (label === "css" || label === "scss" || label === "less") { + return "./css.worker.js"; + } + if (label === "html" || label === "handlebars" || label === "razor") { + return "./html.worker.js"; + } + if (label === "typescript" || label === "javascript") { + return "./ts.worker.js"; + } + return "./editor.worker.js"; + }, + }, + }; + `, + }, + }, + ], + }, ], }, resolve: { @@ -58,9 +85,6 @@ const config = buildBaseWebConfig( }, }, plugins: [ - new DefinePlugin({ - BUILD_ANGULAR_COMPILATION_FLAGS: JSON.stringify(angularCompilationFlags), - }), (() => { type StrictTemplateOptions = NoExtraProps>; @@ -139,6 +163,13 @@ const config = buildBaseWebConfig( { tsConfigFile, chunkName: WEB_CHUNK_NAMES["browser-window"], + entries: { + "css.worker": "monaco-editor/esm/vs/language/css/css.worker", + "editor.worker": "monaco-editor/esm/vs/editor/editor.worker.js", + "html.worker": "monaco-editor/esm/vs/language/html/html.worker", + "json.worker": "monaco-editor/esm/vs/language/json/json.worker", + "ts.worker": "monaco-editor/esm/vs/language/typescript/ts.worker", + }, }, ); diff --git a/webpack-configs/web/lib.ts b/webpack-configs/web/lib.ts index 27a5e40bd..e67d53d10 100644 --- a/webpack-configs/web/lib.ts +++ b/webpack-configs/web/lib.ts @@ -120,9 +120,10 @@ export function buildMinimalWebConfig( export function buildBaseWebConfig( configPatch: Configuration, options: { - tsConfigFile?: string; - chunkName: keyof typeof WEB_CHUNK_NAMES; - typescriptLoader?: boolean; + tsConfigFile?: string + chunkName: keyof typeof WEB_CHUNK_NAMES + typescriptLoader?: boolean + entries?: Record }, ): Configuration { const chunkPath = (...value: string[]): string => { @@ -150,6 +151,7 @@ export function buildBaseWebConfig( { entry: { index: chunkPath("./index.ts"), + ...options.entries, }, module: { rules: [ diff --git a/yarn.lock b/yarn.lock index bab040d71..56245278e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3494,6 +3494,14 @@ dotenv@^8.2.0: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== +dts-generator@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/dts-generator/-/dts-generator-3.0.0.tgz#70ed00dc1067bc66f68ad550bbeb894873d45f77" + integrity sha512-IrFP0TUGnBOxr8Lth0hLh/iM9odZTRGYyr4Y46IRxyw1SGO1Vvf30+x4npck9yP4FbaRXbB0Zh7gvmiqUta7mg== + dependencies: + glob "^7.1.3" + mkdirp "^0.5.1" + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -5373,6 +5381,15 @@ import-sort@^6.0.0: is-builtin-module "^3.0.0" resolve "^1.8.1" +imports-loader@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-2.0.0.tgz#f2f5152c6d8798a286b28a44eeae62142b60aa2c" + integrity sha512-ZwEx0GfsJ1QckGqHSS1uu1sjpUgT3AYFOr3nT07dVnfeyc/bOICSw48067hr0u7DW8TZVzNVvdnvA62U9lG8nQ== + dependencies: + loader-utils "^2.0.0" + source-map "^0.6.1" + strip-comments "^2.0.1" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -6793,6 +6810,11 @@ mkdirp@^1.0.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +monaco-editor@0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.23.0.tgz#24844ba5640c7adb3a2a3ff3b520cf2d7170a6f0" + integrity sha512-q+CP5zMR/aFiMTE9QlIavGyGicKnG2v/H8qVvybLzeFsARM8f6G9fL0sMST2tyVYCwDKkGamZUI6647A0jR/Lg== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -8018,6 +8040,11 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== +quickjs-emscripten@0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/quickjs-emscripten/-/quickjs-emscripten-0.11.0.tgz#c444899ee43d27fbd0668f3699b83a9d882cdc2d" + integrity sha512-adOtvmMDEQD9fsFZXgV+vfnAAjPuvEvYv5QpiS3SXM0fj7NOMkY7uSJ61SdWADcT5xa6n6P7JdYIievqODsDyA== + randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -9231,6 +9258,11 @@ strip-bom@^3.0.0: resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= +strip-comments@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-comments/-/strip-comments-2.0.1.tgz#4ad11c3fbcac177a67a40ac224ca339ca1c1ba9b" + integrity sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw== + strip-final-newline@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"