From 8ee018a759073db4529d3551409a7eb5a4c91491 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 26 Jul 2021 18:51:50 -0400 Subject: [PATCH 1/2] feat: Web concurrency (#2347) * feat: Fork multiple processes * Remove boxen * comment * chore: Add support for Heroku DATABASE_CONNECTION_POOL_URL closes #2306 --- .env.sample | 4 ++++ package.json | 2 +- server/index.js | 18 +++++++++----- server/main.js | 6 +++-- server/sequelize.js | 29 +++++++++++++---------- yarn.lock | 58 +++++++-------------------------------------- 6 files changed, 46 insertions(+), 71 deletions(-) diff --git a/.env.sample b/.env.sample index fabb32f8a..ea69b26bf 100644 --- a/.env.sample +++ b/.env.sample @@ -94,6 +94,10 @@ FORCE_HTTPS=true # the maintainers ENABLE_UPDATES=true +# How many processes should be spawned. As a reasonable rule divide your servers +# available memory by 512 for a rough estimate +WEB_CONCURRENCY=1 + # Override the maxium size of document imports, could be required if you have # especially large Word documents with embedded imagery MAXIMUM_IMPORT_SIZE=5120000 diff --git a/package.json b/package.json index 95325cb40..9a2167177 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "babel-plugin-styled-components": "^1.11.1", "babel-plugin-transform-class-properties": "^6.24.1", "boundless-arrow-key-navigation": "^1.0.4", - "boxen": "^5.0.1", "bull": "^3.5.2", "cancan": "3.1.0", "chalk": "^4.1.0", @@ -160,6 +159,7 @@ "styled-components": "^5.2.3", "styled-components-breakpoint": "^2.1.1", "styled-normalize": "^8.0.4", + "throng": "^5.0.0", "tiny-cookie": "^2.3.1", "tmp": "^0.2.1", "turndown": "^7.1.1", diff --git a/server/index.js b/server/index.js index e56465a00..16172c5bb 100644 --- a/server/index.js +++ b/server/index.js @@ -2,8 +2,8 @@ require("dotenv").config({ silent: true }); const errors = []; -const boxen = require("boxen"); const chalk = require("chalk"); +const throng = require("throng"); // If the DataDog agent is installed and the DD_API_KEY environment variable is // in the environment then we can safely attempt to start the DD tracer @@ -66,7 +66,7 @@ if (!process.env.URL) { ); } -if (!process.env.DATABASE_URL) { +if (!process.env.DATABASE_URL && !process.env.DATABASE_CONNECTION_POOL_URL) { errors.push( `The ${chalk.bold( "DATABASE_URL" @@ -95,11 +95,10 @@ if (errors.length) { if (process.env.NODE_ENV === "production") { console.log( - boxen( + chalk.green( ` Is your team enjoying Outline? Consider supporting future development by sponsoring the project:\n\nhttps://github.com/sponsors/outline -`, - { padding: 1, margin: 1, borderStyle: "double", borderColor: "green" } +` ) ); } else if (process.env.NODE_ENV === "development") { @@ -112,4 +111,11 @@ Is your team enjoying Outline? Consider supporting future development by sponsor ); } -require("./main"); +const { start } = require("./main"); + +throng({ + worker: start, + + // The number of workers to run, defaults to the number of CPUs available + count: process.env.WEB_CONCURRENCY || undefined, +}); diff --git a/server/main.js b/server/main.js index 719ffa2e3..de626eb5a 100644 --- a/server/main.js +++ b/server/main.js @@ -234,10 +234,12 @@ server.on("listening", () => { console.log(`\n> Listening on http://localhost:${address.port}\n`); }); -(async () => { +export async function start(id: string) { + console.log(`Started worker ${id}`); + await checkMigrations(); server.listen(process.env.PORT || "3000"); -})(); +} export const socketio = io; diff --git a/server/sequelize.js b/server/sequelize.js index faaf77c17..f0318984c 100644 --- a/server/sequelize.js +++ b/server/sequelize.js @@ -12,16 +12,19 @@ export const encryptedFields = () => export const DataTypes = Sequelize; export const Op = Sequelize.Op; -export const sequelize = new Sequelize(process.env.DATABASE_URL, { - logging: debug("sql"), - typeValidation: true, - dialectOptions: { - ssl: - isProduction && !isSSLDisabled - ? { - // Ref.: https://github.com/brianc/node-postgres/issues/2009 - rejectUnauthorized: false, - } - : false, - }, -}); +export const sequelize = new Sequelize( + process.env.DATABASE_URL || process.env.DATABASE_CONNECTION_POOL_URL, + { + logging: debug("sql"), + typeValidation: true, + dialectOptions: { + ssl: + isProduction && !isSSLDisabled + ? { + // Ref.: https://github.com/brianc/node-postgres/issues/2009 + rejectUnauthorized: false, + } + : false, + }, + } +); diff --git a/yarn.lock b/yarn.lock index 527802664..e9e7a81c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2490,13 +2490,6 @@ ansi-align@^2.0.0: dependencies: string-width "^2.0.0" -ansi-align@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb" - integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw== - dependencies: - string-width "^3.0.0" - ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -3253,20 +3246,6 @@ boxen@^1.2.1: term-size "^1.2.0" widest-line "^2.0.0" -boxen@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.0.1.tgz#657528bdd3f59a772b8279b831f27ec2c744664b" - integrity sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.0" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -3627,7 +3606,7 @@ camelcase@^5.0.0, camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.0.0, camelcase@^6.2.0: +camelcase@^6.0.0: version "6.2.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== @@ -3858,11 +3837,6 @@ cli-boxes@^1.0.0: resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM= -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - cli-color@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/cli-color/-/cli-color-1.4.0.tgz#7d10738f48526824f8fe7da51857cb0f572fe01f" @@ -12525,7 +12499,7 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0: +string-width@^4.1.0, string-width@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5" integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== @@ -12896,6 +12870,13 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== +throng@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/throng/-/throng-5.0.0.tgz#f9550c0221e579073f68a00be33a593d094e4d29" + integrity sha512-nrq7+qQhn/DL8yW/wiwImTepfi6ynOCAe7moSwgoYN1F32yQMdBkuFII40oAkb3cDfaL6q5BIoFTDCHdMWQ8Pw== + dependencies: + lodash "^4.17.20" + through2-filter@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254" @@ -13214,11 +13195,6 @@ type-fest@^0.16.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.16.0.tgz#3240b891a78b0deae910dbeb86553e552a148860" integrity sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg== -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - type-fest@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" @@ -14010,13 +13986,6 @@ widest-line@^2.0.0: dependencies: string-width "^2.1.1" -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - windows-release@^3.1.0: version "3.3.3" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.3.tgz#1c10027c7225743eec6b89df160d64c2e0293999" @@ -14228,15 +14197,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" From a81fbd8608ec3322649affad1002c30fcb581711 Mon Sep 17 00:00:00 2001 From: Saumya Pandey Date: Tue, 27 Jul 2021 11:31:27 +0530 Subject: [PATCH 2/2] fix: Show tasks completion on document list items (#2342) Co-authored-by: Tom Moor --- app/components/CircularProgressBar.js | 74 +++++++++++++++++++++ app/components/DocumentMeta.js | 18 +++-- app/components/DocumentTasks.js | 33 ++++++++++ app/models/Document.js | 16 +++++ app/scenes/Document/components/Document.js | 3 + server/models/Document.js | 4 ++ server/models/Document.test.js | 76 ++++++++++++++++++++++ server/presenters/document.js | 1 + shared/i18n/locales/en_US/translation.json | 3 + shared/theme.js | 2 + shared/utils/getTasks.js | 21 ++++++ 11 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 app/components/CircularProgressBar.js create mode 100644 app/components/DocumentTasks.js create mode 100644 shared/utils/getTasks.js diff --git a/app/components/CircularProgressBar.js b/app/components/CircularProgressBar.js new file mode 100644 index 000000000..b68bd4baa --- /dev/null +++ b/app/components/CircularProgressBar.js @@ -0,0 +1,74 @@ +// @flow +import React from "react"; +import { useTheme } from "styled-components"; + +const cleanPercentage = (percentage) => { + const tooLow = !Number.isFinite(+percentage) || percentage < 0; + const tooHigh = percentage > 100; + return tooLow ? 0 : tooHigh ? 100 : +percentage; +}; + +const Circle = ({ + color, + percentage, + offset, +}: { + color: string, + percentage?: number, + offset: number, +}) => { + const radius = offset * 0.7; + const circumference = 2 * Math.PI * radius; + let strokePercentage; + if (percentage) { + // because the circle is so small, anything greater than 85% appears like 100% + percentage = percentage > 85 && percentage < 100 ? 85 : percentage; + strokePercentage = percentage + ? ((100 - percentage) * circumference) / 100 + : 0; + } + + return ( + + ); +}; + +const CircularProgressBar = ({ + percentage, + size = 16, +}: { + percentage: number, + size?: number, +}) => { + const theme = useTheme(); + percentage = cleanPercentage(percentage); + const offset = Math.floor(size / 2); + + return ( + + + + {percentage > 0 && ( + + )} + + + ); +}; + +export default CircularProgressBar; diff --git a/app/components/DocumentMeta.js b/app/components/DocumentMeta.js index b1b79964d..fd65f5de2 100644 --- a/app/components/DocumentMeta.js +++ b/app/components/DocumentMeta.js @@ -6,6 +6,7 @@ import { Link } from "react-router-dom"; import styled from "styled-components"; import Document from "models/Document"; import DocumentBreadcrumb from "components/DocumentBreadcrumb"; +import DocumentTasks from "components/DocumentTasks"; import Flex from "components/Flex"; import Time from "components/Time"; import useCurrentUser from "hooks/useCurrentUser"; @@ -64,6 +65,8 @@ function DocumentMeta({ deletedAt, isDraft, lastViewedAt, + isTasks, + isTemplate, } = document; // Prevent meta information from displaying if updatedBy is not available. @@ -114,6 +117,11 @@ function DocumentMeta({ ); } + const nestedDocumentsCount = collection + ? collection.getDocumentChildren(document.id).length + : 0; + const canShowProgressBar = isTasks && !isTemplate; + const timeSinceNow = () => { if (isDraft || !showLastViewed) { return null; @@ -133,10 +141,6 @@ function DocumentMeta({ ); }; - const nestedDocumentsCount = collection - ? collection.getDocumentChildren(document.id).length - : 0; - return ( {lastUpdatedByCurrentUser ? t("You") : updatedBy.name}  @@ -156,6 +160,12 @@ function DocumentMeta({ )}  {timeSinceNow()} + {canShowProgressBar && ( + <> +  •  + + + )} {children} ); diff --git a/app/components/DocumentTasks.js b/app/components/DocumentTasks.js new file mode 100644 index 000000000..c512862dd --- /dev/null +++ b/app/components/DocumentTasks.js @@ -0,0 +1,33 @@ +// @flow +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import CircularProgressBar from "components/CircularProgressBar"; +import Document from "../models/Document"; + +type Props = {| + document: Document, +|}; + +function DocumentTasks({ document }: Props) { + const { tasks, tasksPercentage } = document; + const { t } = useTranslation(); + const { completed, total } = tasks; + const message = + completed === 0 + ? t(`{{ total }} tasks`, { total }) + : completed === total + ? t(`{{ completed }} tasks done`, { completed }) + : t(`{{ completed }} of {{ total }} tasks`, { + total, + completed, + }); + + return ( + <> + +  {message} + + ); +} + +export default DocumentTasks; diff --git a/app/models/Document.js b/app/models/Document.js index 26c384995..93286dca4 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -1,6 +1,7 @@ // @flow import { addDays, differenceInDays } from "date-fns"; import invariant from "invariant"; +import { floor } from "lodash"; import { action, computed, observable, set } from "mobx"; import parseTitle from "shared/utils/parseTitle"; import unescape from "shared/utils/unescape"; @@ -43,6 +44,7 @@ export default class Document extends BaseModel { deletedAt: ?string; url: string; urlId: string; + tasks: { completed: number, total: number }; revision: number; constructor(fields: Object, store: DocumentsStore) { @@ -149,6 +151,20 @@ export default class Document extends BaseModel { get isFromTemplate(): boolean { return !!this.templateId; } + + @computed + get isTasks(): boolean { + return !!this.tasks.total; + } + + @computed + get tasksPercentage(): number { + if (!this.isTasks) { + return 0; + } + return floor((this.tasks.completed / this.tasks.total) * 100); + } + @action share = async () => { return this.store.rootStore.shares.create({ documentId: this.id }); diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index 0c6499e76..d825da7ac 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -10,6 +10,7 @@ import { Prompt, Route, withRouter } from "react-router-dom"; import type { RouterHistory, Match } from "react-router-dom"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import getTasks from "shared/utils/getTasks"; import AuthStore from "stores/AuthStore"; import ToastsStore from "stores/ToastsStore"; import UiStore from "stores/UiStore"; @@ -223,6 +224,8 @@ class DocumentScene extends React.Component { this.isSaving = true; this.isPublishing = !!options.publish; + document.tasks = getTasks(document.text); + try { const savedDocument = await document.save({ ...options, diff --git a/server/models/Document.js b/server/models/Document.js index 472aab664..78e5cff57 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -6,6 +6,7 @@ import Sequelize, { Transaction } from "sequelize"; import MarkdownSerializer from "slate-md-serializer"; import isUUID from "validator/lib/isUUID"; import { MAX_TITLE_LENGTH } from "../../shared/constants"; +import getTasks from "../../shared/utils/getTasks"; import parseTitle from "../../shared/utils/parseTitle"; import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers"; import unescape from "../../shared/utils/unescape"; @@ -106,6 +107,9 @@ const Document = sequelize.define( const slugifiedTitle = slugify(this.title); return `/doc/${slugifiedTitle}-${this.urlId}`; }, + tasks: function () { + return getTasks(this.text || ""); + }, }, } ); diff --git a/server/models/Document.test.js b/server/models/Document.test.js index 6f24a47b1..16f6e126b 100644 --- a/server/models/Document.test.js +++ b/server/models/Document.test.js @@ -430,3 +430,79 @@ describe("#findByPk", () => { expect(response.id).toBe(document.id); }); }); + +describe("tasks", () => { + test("should consider all the possible checkTtems", async () => { + const document = await buildDocument({ + text: `- [x] test + - [X] test + - [ ] test + - [-] test + - [_] test`, + }); + + const tasks = document.tasks; + + expect(tasks.completed).toBe(4); + expect(tasks.total).toBe(5); + }); + + test("should return tasks keys set to 0 if checkItems isn't present", async () => { + const document = await buildDocument({ + text: `text`, + }); + + const tasks = document.tasks; + + expect(tasks.completed).toBe(0); + expect(tasks.total).toBe(0); + }); + + test("should return tasks keys set to 0 if the text contains broken checkItems", async () => { + const document = await buildDocument({ + text: `- [x ] test + - [ x ] test + - [ ] test`, + }); + + const tasks = document.tasks; + + expect(tasks.completed).toBe(0); + expect(tasks.total).toBe(0); + }); + + test("should return tasks", async () => { + const document = await buildDocument({ + text: `- [x] list item + - [ ] list item`, + }); + + const tasks = document.tasks; + + expect(tasks.completed).toBe(1); + expect(tasks.total).toBe(2); + }); + + test("should update tasks on save", async () => { + const document = await buildDocument({ + text: `- [x] list item + - [ ] list item`, + }); + + const tasks = document.tasks; + + expect(tasks.completed).toBe(1); + expect(tasks.total).toBe(2); + + document.text = `- [x] list item + - [ ] list item + - [ ] list item`; + + await document.save(); + + const newTasks = document.tasks; + + expect(newTasks.completed).toBe(1); + expect(newTasks.total).toBe(3); + }); +}); diff --git a/server/presenters/document.js b/server/presenters/document.js index c98893d72..3a83716c7 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -44,6 +44,7 @@ export default async function present(document: Document, options: ?Options) { title: document.title, text, emoji: document.emoji, + tasks: document.tasks, createdAt: document.createdAt, createdBy: undefined, updatedAt: document.updatedAt, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 4e7022d93..33a916485 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -34,6 +34,9 @@ "only you": "only you", "person": "person", "people": "people", + "{{ total }} tasks": "{{ total }} tasks", + "{{ completed }} tasks done": "{{ completed }} tasks done", + "{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks", "Currently editing": "Currently editing", "Currently viewing": "Currently viewing", "Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago", diff --git a/shared/theme.js b/shared/theme.js index 9b64c4f9e..496f2e42e 100644 --- a/shared/theme.js +++ b/shared/theme.js @@ -179,6 +179,7 @@ export const light = { noticeInfoBackground: colors.warmGrey, noticeInfoText: colors.almostBlack, + progressBarBackground: colors.slateLight, scrollbarBackground: colors.smoke, scrollbarThumb: darken(0.15, colors.smokeDark), @@ -241,6 +242,7 @@ export const dark = { noticeInfoBackground: colors.white10, noticeInfoText: colors.almostWhite, + progressBarBackground: colors.slate, scrollbarBackground: colors.black, scrollbarThumb: colors.lightBlack, diff --git a/shared/utils/getTasks.js b/shared/utils/getTasks.js new file mode 100644 index 000000000..2a9fe6349 --- /dev/null +++ b/shared/utils/getTasks.js @@ -0,0 +1,21 @@ +// @flow + +const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/gi; + +export default function getTasks(text: string) { + const matches = [...text.matchAll(CHECKBOX_REGEX)]; + let total = matches.length; + if (!total) { + return { + completed: 0, + total: 0, + }; + } else { + const notCompleted = matches.reduce( + (accumulator, match) => + match[1] === " " ? accumulator + 1 : accumulator, + 0 + ); + return { completed: total - notCompleted, total }; + } +}