diff --git a/.gitignore b/.gitignore index 1c1d25826..df445813e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ npm-debug.log stats.json .DS_Store fakes3/* +.idea diff --git a/app/components/Badge.js b/app/components/Badge.js index 646fede8d..ea3ac1f5f 100644 --- a/app/components/Badge.js +++ b/app/components/Badge.js @@ -4,9 +4,10 @@ import styled from "styled-components"; const Badge = styled.span` margin-left: 10px; padding: 2px 6px 3px; - background-color: ${({ primary, theme }) => - primary ? theme.primary : theme.textTertiary}; - color: ${({ primary, theme }) => (primary ? theme.white : theme.background)}; + background-color: ${({ yellow, primary, theme }) => + yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary}; + color: ${({ primary, yellow, theme }) => + primary ? theme.white : yellow ? theme.almostBlack : theme.background}; border-radius: 4px; font-size: 11px; font-weight: 500; diff --git a/app/components/DocumentMeta.js b/app/components/DocumentMeta.js index 4f2366b88..31a96d403 100644 --- a/app/components/DocumentMeta.js +++ b/app/components/DocumentMeta.js @@ -18,8 +18,7 @@ const Container = styled(Flex)` `; const Modified = styled.span` - color: ${(props) => - props.highlight ? props.theme.text : props.theme.textTertiary}; + color: ${(props) => props.theme.textTertiary}; font-weight: ${(props) => (props.highlight ? "600" : "400")}; `; @@ -28,6 +27,7 @@ type Props = { auth: AuthStore, showCollection?: boolean, showPublished?: boolean, + showLastViewed?: boolean, document: Document, children: React.Node, to?: string, @@ -38,6 +38,7 @@ function DocumentMeta({ collections, showPublished, showCollection, + showLastViewed, document, children, to, @@ -52,6 +53,7 @@ function DocumentMeta({ archivedAt, deletedAt, isDraft, + lastViewedAt, } = document; // Prevent meta information from displaying if updatedBy is not available. @@ -65,37 +67,37 @@ function DocumentMeta({ if (deletedAt) { content = ( - deleted ); } else if (archivedAt) { content = ( - archived ); } else if (createdAt === updatedAt) { content = ( - created ); } else if (publishedAt && (publishedAt === updatedAt || showPublished)) { content = ( - published ); } else if (isDraft) { content = ( - saved ); } else { content = ( - updated ); } @@ -103,6 +105,25 @@ function DocumentMeta({ const collection = collections.get(document.collectionId); const updatedByMe = auth.user && auth.user.id === updatedBy.id; + const timeSinceNow = () => { + if (isDraft || !showLastViewed) { + return null; + } + if (!lastViewedAt) { + return ( + <> + • Never viewed + + ); + } + + return ( + + • Viewed + ); + }; + return ( {updatedByMe ? "You" : updatedBy.name}  @@ -115,6 +136,7 @@ function DocumentMeta({ )} +  {timeSinceNow()} {children} ); diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 371f44aca..4c91e0094 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -1,8 +1,9 @@ // @flow +import { observable } from "mobx"; import { observer } from "mobx-react"; import { StarredIcon, PlusIcon } from "outline-icons"; import * as React from "react"; -import { Link, withRouter, type RouterHistory } from "react-router-dom"; +import { Link, Redirect } from "react-router-dom"; import styled, { withTheme } from "styled-components"; import Document from "models/Document"; import Badge from "components/Badge"; @@ -15,7 +16,6 @@ import DocumentMenu from "menus/DocumentMenu"; import { newDocumentUrl } from "utils/routeHelpers"; type Props = { - history: RouterHistory, document: Document, highlight?: ?string, context?: ?string, @@ -30,6 +30,8 @@ const SEARCH_RESULT_REGEX = /]*>(.*?)<\/b>/gi; @observer class DocumentPreview extends React.Component { + @observable redirectTo: ?string; + handleStar = (ev: SyntheticEvent<>) => { ev.preventDefault(); ev.stopPropagation(); @@ -48,17 +50,15 @@ class DocumentPreview extends React.Component { return tag.replace(/]*>(.*?)<\/b>/gi, "$1"); }; - handleNewFromTemplate = (event) => { + handleNewFromTemplate = (event: SyntheticEvent<>) => { event.preventDefault(); event.stopPropagation(); const { document } = this.props; - this.props.history.push( - newDocumentUrl(document.collectionId, { - templateId: document.id, - }) - ); + this.redirectTo = newDocumentUrl(document.collectionId, { + templateId: document.id, + }); }; render() { @@ -73,6 +73,10 @@ class DocumentPreview extends React.Component { context, } = this.props; + if (this.redirectTo) { + return ; + } + const queryIsInTitle = !!highlight && !!document.title.toLowerCase().includes(highlight.toLowerCase()); @@ -86,6 +90,7 @@ class DocumentPreview extends React.Component { > + {document.isNew && <Badge yellow>New</Badge>} {!document.isDraft && !document.isArchived && !document.isTemplate && ( @@ -133,6 +138,7 @@ class DocumentPreview extends React.Component<Props> { document={document} showCollection={showCollection} showPublished={showPublished} + showLastViewed /> </DocumentLink> ); @@ -228,4 +234,4 @@ const ResultContext = styled(Highlight)` margin-bottom: 0.25em; `; -export default withRouter(DocumentPreview); +export default DocumentPreview; diff --git a/app/components/Highlight.js b/app/components/Highlight.js index c31e01c55..dea98fd57 100644 --- a/app/components/Highlight.js +++ b/app/components/Highlight.js @@ -38,7 +38,7 @@ function Highlight({ } const Mark = styled.mark` - background: ${(props) => props.theme.yellow}; + background: ${(props) => props.theme.searchHighlight}; border-radius: 2px; padding: 0 4px; `; diff --git a/app/components/InputSearch.js b/app/components/InputSearch.js index 526767ec0..7d594580f 100644 --- a/app/components/InputSearch.js +++ b/app/components/InputSearch.js @@ -12,6 +12,7 @@ import { searchUrl } from "utils/routeHelpers"; type Props = { history: RouterHistory, theme: Object, + source: string, placeholder?: string, collectionId?: string, }; @@ -33,7 +34,10 @@ class InputSearch extends React.Component<Props> { handleSearchInput = (ev) => { ev.preventDefault(); this.props.history.push( - searchUrl(ev.target.value, this.props.collectionId) + searchUrl(ev.target.value, { + collectionId: this.props.collectionId, + ref: this.props.source, + }) ); }; diff --git a/app/components/Time.js b/app/components/Time.js index d7b55f329..927984eed 100644 --- a/app/components/Time.js +++ b/app/components/Time.js @@ -24,6 +24,8 @@ type Props = { dateTime: string, children?: React.Node, tooltipDelay?: number, + addSuffix?: boolean, + shorten?: boolean, }; class Time extends React.Component<Props> { @@ -40,6 +42,18 @@ class Time extends React.Component<Props> { } render() { + const { shorten, addSuffix } = this.props; + let content = distanceInWordsToNow(this.props.dateTime, { + addSuffix, + }); + + if (shorten) { + content = content + .replace("about", "") + .replace("less than a minute ago", "just now") + .replace("minute", "min"); + } + return ( <Tooltip tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")} @@ -47,7 +61,7 @@ class Time extends React.Component<Props> { placement="bottom" > <time dateTime={this.props.dateTime}> - {this.props.children || distanceInWordsToNow(this.props.dateTime)} + {this.props.children || content} </time> </Tooltip> ); diff --git a/app/embeds/Abstract.js b/app/embeds/Abstract.js index f655c0701..f49a7c049 100644 --- a/app/embeds/Abstract.js +++ b/app/embeds/Abstract.js @@ -21,6 +21,7 @@ export default class Abstract extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://app.goabstract.com/embed/${shareId}`} title={`Abstract (${shareId})`} /> diff --git a/app/embeds/Airtable.js b/app/embeds/Airtable.js index e77755228..69f9b49f9 100644 --- a/app/embeds/Airtable.js +++ b/app/embeds/Airtable.js @@ -20,6 +20,7 @@ export default class Airtable extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://airtable.com/embed/${shareId}`} title={`Airtable (${shareId})`} border diff --git a/app/embeds/ClickUp.js b/app/embeds/ClickUp.js index f09da5ecc..f6683ee98 100644 --- a/app/embeds/ClickUp.js +++ b/app/embeds/ClickUp.js @@ -17,6 +17,12 @@ export default class ClickUp extends React.Component<Props> { static ENABLED = [URL_REGEX]; render() { - return <Frame src={this.props.attrs.href} title="ClickUp Embed" />; + return ( + <Frame + {...this.props} + src={this.props.attrs.href} + title="ClickUp Embed" + /> + ); } } diff --git a/app/embeds/Codepen.js b/app/embeds/Codepen.js index e809cc67c..69c446131 100644 --- a/app/embeds/Codepen.js +++ b/app/embeds/Codepen.js @@ -17,6 +17,6 @@ export default class Codepen extends React.Component<Props> { render() { const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/"); - return <Frame src={normalizedUrl} title="Codepen Embed" />; + return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />; } } diff --git a/app/embeds/Figma.js b/app/embeds/Figma.js index 15a3f9c3e..234c522d8 100644 --- a/app/embeds/Figma.js +++ b/app/embeds/Figma.js @@ -19,6 +19,7 @@ export default class Figma extends React.Component<Props> { render() { return ( <Frame + {...this.props} src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`} title="Figma Embed" border diff --git a/app/embeds/Framer.js b/app/embeds/Framer.js index 0b58f7c48..fdb052671 100644 --- a/app/embeds/Framer.js +++ b/app/embeds/Framer.js @@ -15,6 +15,13 @@ export default class Framer extends React.Component<Props> { static ENABLED = [URL_REGEX]; render() { - return <Frame src={this.props.attrs.href} title="Framer Embed" border />; + return ( + <Frame + {...this.props} + src={this.props.attrs.href} + title="Framer Embed" + border + /> + ); } } diff --git a/app/embeds/Gist.js b/app/embeds/Gist.js index d70c00728..3dab38639 100644 --- a/app/embeds/Gist.js +++ b/app/embeds/Gist.js @@ -6,6 +6,7 @@ const URL_REGEX = new RegExp( ); type Props = {| + isSelected: boolean, attrs: {| href: string, matches: string[], @@ -48,6 +49,7 @@ class Gist extends React.Component<Props> { return ( <iframe + className={this.props.isSelected ? "ProseMirror-selectednode" : ""} ref={this.updateIframeContent} type="text/html" frameBorder="0" diff --git a/app/embeds/GoogleDocs.js b/app/embeds/GoogleDocs.js index 0402f0203..491a8a57d 100644 --- a/app/embeds/GoogleDocs.js +++ b/app/embeds/GoogleDocs.js @@ -17,6 +17,7 @@ export default class GoogleDocs extends React.Component<Props> { render() { return ( <Frame + {...this.props} src={this.props.attrs.href.replace("/edit", "/preview")} icon={ <img diff --git a/app/embeds/GoogleSheets.js b/app/embeds/GoogleSheets.js index 8e38b4fb0..d087c134a 100644 --- a/app/embeds/GoogleSheets.js +++ b/app/embeds/GoogleSheets.js @@ -17,6 +17,7 @@ export default class GoogleSlides extends React.Component<Props> { render() { return ( <Frame + {...this.props} src={this.props.attrs.href.replace("/edit", "/preview")} icon={ <img diff --git a/app/embeds/GoogleSlides.js b/app/embeds/GoogleSlides.js index c1af0a20c..3fa9866de 100644 --- a/app/embeds/GoogleSlides.js +++ b/app/embeds/GoogleSlides.js @@ -17,6 +17,7 @@ export default class GoogleSlides extends React.Component<Props> { render() { return ( <Frame + {...this.props} src={this.props.attrs.href .replace("/edit", "/preview") .replace("/pub", "/embed")} diff --git a/app/embeds/InVision.js b/app/embeds/InVision.js index 80ea9d18a..cbc698c24 100644 --- a/app/embeds/InVision.js +++ b/app/embeds/InVision.js @@ -12,6 +12,7 @@ const IMAGE_REGEX = new RegExp( ); type Props = {| + isSelected: boolean, attrs: {| href: string, matches: string[], @@ -25,6 +26,7 @@ export default class InVision extends React.Component<Props> { if (IMAGE_REGEX.test(this.props.attrs.href)) { return ( <ImageZoom + className={this.props.isSelected ? "ProseMirror-selectednode" : ""} image={{ src: this.props.attrs.href, alt: "InVision Embed", @@ -37,6 +39,12 @@ export default class InVision extends React.Component<Props> { /> ); } - return <Frame src={this.props.attrs.href} title="InVision Embed" />; + return ( + <Frame + {...this.props} + src={this.props.attrs.href} + title="InVision Embed" + /> + ); } } diff --git a/app/embeds/Loom.js b/app/embeds/Loom.js index 29eb5f665..f182a8fc2 100644 --- a/app/embeds/Loom.js +++ b/app/embeds/Loom.js @@ -17,6 +17,6 @@ export default class Loom extends React.Component<Props> { render() { const normalizedUrl = this.props.attrs.href.replace("share", "embed"); - return <Frame src={normalizedUrl} title="Loom Embed" />; + return <Frame {...this.props} src={normalizedUrl} title="Loom Embed" />; } } diff --git a/app/embeds/Lucidchart.js b/app/embeds/Lucidchart.js index 9c5ee01c2..281897458 100644 --- a/app/embeds/Lucidchart.js +++ b/app/embeds/Lucidchart.js @@ -20,6 +20,7 @@ export default class Lucidchart extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://lucidchart.com/documents/embeddedchart/${chartId}`} title="Lucidchart Embed" /> diff --git a/app/embeds/Marvel.js b/app/embeds/Marvel.js index ee60cfd0f..8175f3c01 100644 --- a/app/embeds/Marvel.js +++ b/app/embeds/Marvel.js @@ -15,6 +15,13 @@ export default class Marvel extends React.Component<Props> { static ENABLED = [URL_REGEX]; render() { - return <Frame src={this.props.attrs.href} title="Marvel Embed" border />; + return ( + <Frame + {...this.props} + src={this.props.attrs.href} + title="Marvel Embed" + border + /> + ); } } diff --git a/app/embeds/Mindmeister.js b/app/embeds/Mindmeister.js index f71727334..42c976467 100644 --- a/app/embeds/Mindmeister.js +++ b/app/embeds/Mindmeister.js @@ -21,6 +21,7 @@ export default class Mindmeister extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`} title="Mindmeister Embed" border diff --git a/app/embeds/Miro.js b/app/embeds/Miro.js index 0c9943558..51e7ccb2d 100644 --- a/app/embeds/Miro.js +++ b/app/embeds/Miro.js @@ -20,6 +20,7 @@ export default class RealtimeBoard extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://realtimeboard.com/app/embed/${boardId}`} title={`RealtimeBoard (${boardId})`} /> diff --git a/app/embeds/ModeAnalytics.js b/app/embeds/ModeAnalytics.js index 282f2a09c..89851e227 100644 --- a/app/embeds/ModeAnalytics.js +++ b/app/embeds/ModeAnalytics.js @@ -21,7 +21,11 @@ export default class ModeAnalytics extends React.Component<Props> { const normalizedUrl = this.props.attrs.href.replace(/\/embed$/, ""); return ( - <Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" /> + <Frame + {...this.props} + src={`${normalizedUrl}/embed`} + title="Mode Analytics Embed" + /> ); } } diff --git a/app/embeds/Prezi.js b/app/embeds/Prezi.js index 05e356480..4a79f40c3 100644 --- a/app/embeds/Prezi.js +++ b/app/embeds/Prezi.js @@ -17,6 +17,8 @@ export default class Prezi extends React.Component<Props> { render() { const url = this.props.attrs.href.replace(/\/embed$/, ""); - return <Frame src={`${url}/embed`} title="Prezi Embed" border />; + return ( + <Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border /> + ); } } diff --git a/app/embeds/Spotify.js b/app/embeds/Spotify.js index 9b08932ca..317283091 100644 --- a/app/embeds/Spotify.js +++ b/app/embeds/Spotify.js @@ -27,6 +27,7 @@ export default class Spotify extends React.Component<Props> { return ( <Frame + {...this.props} width="300px" height="380px" src={`https://open.spotify.com/embed${normalizedPath}`} diff --git a/app/embeds/Trello.js b/app/embeds/Trello.js index 991385a49..d9a5edeee 100644 --- a/app/embeds/Trello.js +++ b/app/embeds/Trello.js @@ -31,6 +31,7 @@ export default class Trello extends React.Component<Props> { return ( <Frame + {...this.props} width="248px" height="185px" src={`https://trello.com/embed/board?id=${objectId}`} diff --git a/app/embeds/Typeform.js b/app/embeds/Typeform.js index 9e113e629..2fcccef27 100644 --- a/app/embeds/Typeform.js +++ b/app/embeds/Typeform.js @@ -17,6 +17,12 @@ export default class Typeform extends React.Component<Props> { static ENABLED = [URL_REGEX]; render() { - return <Frame src={this.props.attrs.href} title="Typeform Embed" />; + return ( + <Frame + {...this.props} + src={this.props.attrs.href} + title="Typeform Embed" + /> + ); } } diff --git a/app/embeds/Vimeo.js b/app/embeds/Vimeo.js index 49aed7d87..b5a32a806 100644 --- a/app/embeds/Vimeo.js +++ b/app/embeds/Vimeo.js @@ -20,6 +20,7 @@ export default class Vimeo extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://player.vimeo.com/video/${videoId}?byline=0`} title={`Vimeo Embed (${videoId})`} /> diff --git a/app/embeds/YouTube.js b/app/embeds/YouTube.js index 1de894102..a0687aa1e 100644 --- a/app/embeds/YouTube.js +++ b/app/embeds/YouTube.js @@ -5,6 +5,7 @@ import Frame from "./components/Frame"; const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i; type Props = {| + isSelected: boolean, attrs: {| href: string, matches: string[], @@ -20,6 +21,7 @@ export default class YouTube extends React.Component<Props> { return ( <Frame + {...this.props} src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`} title={`YouTube (${videoId})`} /> diff --git a/app/embeds/components/Frame.js b/app/embeds/components/Frame.js index 0988e35fb..9afb93e33 100644 --- a/app/embeds/components/Frame.js +++ b/app/embeds/components/Frame.js @@ -12,6 +12,7 @@ type Props = { title?: string, icon?: React.Node, canonicalUrl?: string, + isSelected?: boolean, width?: string, height?: string, }; @@ -48,13 +49,19 @@ class Frame extends React.Component<PropsWithRef> { icon, title, canonicalUrl, - ...rest + isSelected, + src, } = this.props; const Component = border ? StyledIframe : "iframe"; const withBar = !!(icon || canonicalUrl); return ( - <Rounded width={width} height={height} withBar={withBar}> + <Rounded + width={width} + height={height} + withBar={withBar} + className={isSelected ? "ProseMirror-selectednode" : ""} + > {this.isLoaded && ( <Component ref={forwardedRef} @@ -66,8 +73,8 @@ class Frame extends React.Component<PropsWithRef> { frameBorder="0" title="embed" loading="lazy" + src={src} allowFullScreen - {...rest} /> )} {withBar && ( diff --git a/app/models/Document.js b/app/models/Document.js index 7538fe927..1ec8134f9 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -1,5 +1,6 @@ // @flow import addDays from "date-fns/add_days"; +import differenceInDays from "date-fns/difference_in_days"; import invariant from "invariant"; import { action, computed, observable, set } from "mobx"; import parseTitle from "shared/utils/parseTitle"; @@ -7,6 +8,7 @@ import unescape from "shared/utils/unescape"; import DocumentsStore from "stores/DocumentsStore"; import BaseModel from "models/BaseModel"; import User from "models/User"; +import View from "./View"; type SaveOptions = { publish?: boolean, @@ -19,11 +21,11 @@ export default class Document extends BaseModel { @observable isSaving: boolean = false; @observable embedsDisabled: boolean = false; @observable injectTemplate: boolean = false; + @observable lastViewedAt: ?string; store: DocumentsStore; collaborators: User[]; collectionId: string; - lastViewedAt: ?string; createdAt: string; createdBy: User; updatedAt: string; @@ -47,7 +49,7 @@ export default class Document extends BaseModel { constructor(fields: Object, store: DocumentsStore) { super(fields, store); - if (this.isNew && this.isFromTemplate) { + if (this.isNewDocument && this.isFromTemplate) { this.title = ""; } } @@ -72,6 +74,14 @@ export default class Document extends BaseModel { return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt; } + @computed + get isNew(): boolean { + return ( + !this.lastViewedAt && + differenceInDays(new Date(), new Date(this.createdAt)) < 14 + ); + } + @computed get isStarred(): boolean { return !!this.store.starredIds.get(this.id); @@ -112,7 +122,7 @@ export default class Document extends BaseModel { } @computed - get isNew(): boolean { + get isNewDocument(): boolean { return this.createdAt === this.updatedAt; } @@ -199,6 +209,11 @@ export default class Document extends BaseModel { return this.store.rootStore.views.create({ documentId: this.id }); }; + @action + updateLastViewed = (view: View) => { + this.lastViewedAt = view.lastViewedAt; + }; + @action templatize = async () => { return this.store.templatize(this.id); diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index a0bc5d3d7..40729510c 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -140,6 +140,7 @@ class CollectionScene extends React.Component<Props> { <> <Action> <InputSearch + source="collection" placeholder="Search in collection…" collectionId={match.params.id} /> diff --git a/app/scenes/Dashboard.js b/app/scenes/Dashboard.js index 44d82d06b..b0d5d7880 100644 --- a/app/scenes/Dashboard.js +++ b/app/scenes/Dashboard.js @@ -67,7 +67,7 @@ class Dashboard extends React.Component<Props> { </Switch> <Actions align="center" justify="flex-end"> <Action> - <InputSearch /> + <InputSearch source="dashboard" /> </Action> <Action> <NewDocumentMenu /> diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js index 5cc625b7e..03d97ce06 100644 --- a/app/scenes/Document/components/DataLoader.js +++ b/app/scenes/Document/components/DataLoader.js @@ -1,5 +1,7 @@ // @flow +import distanceInWordsToNow from "date-fns/distance_in_words_to_now"; import invariant from "invariant"; +import { deburr, sortBy } from "lodash"; import { observable } from "mobx"; import { observer, inject } from "mobx-react"; import * as React from "react"; @@ -77,9 +79,13 @@ class DataLoader extends React.Component<Props> { const slug = parseDocumentSlug(term); try { const document = await this.props.documents.fetch(slug); + const time = distanceInWordsToNow(document.updatedAt, { + addSuffix: true, + }); return [ { title: document.title, + subtitle: `Updated ${time}`, url: document.url, }, ]; @@ -92,14 +98,26 @@ class DataLoader extends React.Component<Props> { } // default search for anything that doesn't look like a URL - const results = await this.props.documents.search(term); + const results = await this.props.documents.searchTitles(term); - return results - .filter((result) => result.document.title) - .map((result) => ({ - title: result.document.title, - url: result.document.url, - })); + return sortBy( + results.map((document) => { + const time = distanceInWordsToNow(document.updatedAt, { + addSuffix: true, + }); + return { + title: document.title, + subtitle: `Updated ${time}`, + url: document.url, + }; + }), + (document) => + deburr(document.title) + .toLowerCase() + .startsWith(deburr(term).toLowerCase()) + ? -1 + : 1 + ); }; onCreateLink = async (title: string) => { diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 385fe8a84..9180f23ab 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -21,7 +21,7 @@ type Props = { isDraft: boolean, isShare: boolean, readOnly?: boolean, - onSave: () => mixed, + onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed, innerRef: { current: any }, }; @@ -50,8 +50,13 @@ class DocumentEditor extends React.Component<Props> { }; handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => { - if (event.key === "Enter" && !event.metaKey) { + if (event.key === "Enter") { event.preventDefault(); + if (event.metaKey) { + this.props.onSave({ publish: true, done: true }); + return; + } + this.insertParagraph(); this.focusAtStart(); return; @@ -63,7 +68,7 @@ class DocumentEditor extends React.Component<Props> { } if (event.key === "s" && event.metaKey) { event.preventDefault(); - this.props.onSave(); + this.props.onSave({}); return; } }; diff --git a/app/scenes/Document/components/MarkAsViewed.js b/app/scenes/Document/components/MarkAsViewed.js index 8a12504ba..5c74f388e 100644 --- a/app/scenes/Document/components/MarkAsViewed.js +++ b/app/scenes/Document/components/MarkAsViewed.js @@ -15,9 +15,10 @@ class MarkAsViewed extends React.Component<Props> { componentDidMount() { const { document } = this.props; - this.viewTimeout = setTimeout(() => { + this.viewTimeout = setTimeout(async () => { if (document.publishedAt) { - document.view(); + const view = await document.view(); + document.updateLastViewed(view); } }, MARK_AS_VIEWED_AFTER); } diff --git a/app/scenes/Drafts.js b/app/scenes/Drafts.js index f514f0a12..14ae4724e 100644 --- a/app/scenes/Drafts.js +++ b/app/scenes/Drafts.js @@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> { <Actions align="center" justify="flex-end"> <Action> - <InputSearch /> + <InputSearch source="drafts" /> </Action> <Action> <NewDocumentMenu /> diff --git a/app/scenes/Error404.js b/app/scenes/Error404.js index f4f30847c..b1b94158e 100644 --- a/app/scenes/Error404.js +++ b/app/scenes/Error404.js @@ -1,5 +1,6 @@ // @flow import * as React from "react"; +import { Link } from "react-router-dom"; import CenteredContent from "components/CenteredContent"; import Empty from "components/Empty"; import PageTitle from "components/PageTitle"; @@ -10,8 +11,8 @@ const Error404 = () => { <PageTitle title="Not Found" /> <h1>Not found</h1> <Empty> - We were unable to find the page you’re looking for. Go to the  - <a href="/">homepage</a>? + We were unable to find the page you’re looking for. Go to the{" "} + <Link to="/home">homepage</Link>? </Empty> </CenteredContent> ); diff --git a/app/scenes/Search/Search.js b/app/scenes/Search/Search.js index 6181fe83e..33e5404b1 100644 --- a/app/scenes/Search/Search.js +++ b/app/scenes/Search/Search.js @@ -49,13 +49,14 @@ type Props = { @observer class Search extends React.Component<Props> { firstDocument: ?React.Component<typeof DocumentPreview>; + lastQuery: string = ""; @observable query: string = decodeURIComponent(this.props.match.params.term || ""); @observable params: URLSearchParams = new URLSearchParams(); @observable offset: number = 0; @observable allowLoadMore: boolean = true; - @observable isFetching: boolean = false; + @observable isLoading: boolean = false; @observable pinToTop: boolean = !!this.props.match.params.term; componentDidMount() { @@ -81,14 +82,17 @@ class Search extends React.Component<Props> { } handleKeyDown = (ev) => { - // Escape - if (ev.which === 27) { - ev.preventDefault(); - this.goBack(); + if (ev.key === "Enter") { + this.fetchResults(); + return; } - // Down - if (ev.which === 40) { + if (ev.key === "Escape") { + ev.preventDefault(); + return this.goBack(); + } + + if (ev.key === "ArrowDown") { ev.preventDefault(); if (this.firstDocument) { const element = ReactDOM.findDOMNode(this.firstDocument); @@ -103,7 +107,7 @@ class Search extends React.Component<Props> { this.allowLoadMore = true; // To prevent "no results" showing before debounce kicks in - this.isFetching = true; + this.isLoading = true; this.fetchResultsDebounced(); }; @@ -115,7 +119,7 @@ class Search extends React.Component<Props> { this.allowLoadMore = true; // To prevent "no results" showing before debounce kicks in - this.isFetching = !!this.query; + this.isLoading = !!this.query; this.fetchResultsDebounced(); }; @@ -174,7 +178,7 @@ class Search extends React.Component<Props> { @action loadMoreResults = async () => { // Don't paginate if there aren't more results or we’re in the middle of fetching - if (!this.allowLoadMore || this.isFetching) return; + if (!this.allowLoadMore || this.isLoading) return; // Fetch more results await this.fetchResults(); @@ -183,7 +187,14 @@ class Search extends React.Component<Props> { @action fetchResults = async () => { if (this.query) { - this.isFetching = true; + // we just requested this thing – no need to try again + if (this.lastQuery === this.query) { + this.isLoading = false; + return; + } + + this.isLoading = true; + this.lastQuery = this.query; try { const results = await this.props.documents.search(this.query, { @@ -203,15 +214,19 @@ class Search extends React.Component<Props> { } else { this.offset += DEFAULT_PAGINATION_LIMIT; } + } catch (err) { + this.lastQuery = ""; + throw err; } finally { - this.isFetching = false; + this.isLoading = false; } } else { this.pinToTop = false; + this.lastQuery = this.query; } }; - fetchResultsDebounced = debounce(this.fetchResults, 350, { + fetchResultsDebounced = debounce(this.fetchResults, 500, { leading: false, trailing: true, }); @@ -231,14 +246,14 @@ class Search extends React.Component<Props> { render() { const { documents, notFound, location } = this.props; const results = documents.searchResults(this.query); - const showEmpty = !this.isFetching && this.query && results.length === 0; + const showEmpty = !this.isLoading && this.query && results.length === 0; const showShortcutTip = !this.pinToTop && location.state && location.state.fromMenu; return ( <Container auto> <PageTitle title={this.title} /> - {this.isFetching && <LoadingIndicator />} + {this.isLoading && <LoadingIndicator />} {notFound && ( <div> <h1>Not Found</h1> diff --git a/app/scenes/Starred.js b/app/scenes/Starred.js index 25000f7ac..8ca71611c 100644 --- a/app/scenes/Starred.js +++ b/app/scenes/Starred.js @@ -49,7 +49,7 @@ class Starred extends React.Component<Props> { <Actions align="center" justify="flex-end"> <Action> - <InputSearch /> + <InputSearch source="starred" /> </Action> <Action> <NewDocumentMenu /> diff --git a/app/scenes/UserDelete.js b/app/scenes/UserDelete.js index 8f5128eda..c03e004c2 100644 --- a/app/scenes/UserDelete.js +++ b/app/scenes/UserDelete.js @@ -3,6 +3,7 @@ import { observable } from "mobx"; import { inject, observer } from "mobx-react"; import * as React from "react"; import AuthStore from "stores/AuthStore"; +import UiStore from "stores/UiStore"; import Button from "components/Button"; import Flex from "components/Flex"; import HelpText from "components/HelpText"; @@ -10,6 +11,7 @@ import Modal from "components/Modal"; type Props = { auth: AuthStore, + ui: UiStore, onRequestClose: () => void, }; @@ -24,6 +26,9 @@ class UserDelete extends React.Component<Props> { try { await this.props.auth.deleteUser(); this.props.auth.logout(); + } catch (error) { + this.props.ui.showToast(error.message); + throw error; } finally { this.isDeleting = false; } @@ -56,4 +61,4 @@ class UserDelete extends React.Component<Props> { } } -export default inject("auth")(UserDelete); +export default inject("auth", "ui")(UserDelete); diff --git a/app/stores/BaseStore.js b/app/stores/BaseStore.js index eab617e52..a8f97e929 100644 --- a/app/stores/BaseStore.js +++ b/app/stores/BaseStore.js @@ -114,7 +114,7 @@ export default class BaseStore<T: BaseModel> { } @action - async delete(item: T, options?: Object = {}) { + async delete(item: T, options: Object = {}) { if (!this.actions.includes("delete")) { throw new Error(`Cannot delete ${this.modelName}`); } @@ -132,7 +132,7 @@ export default class BaseStore<T: BaseModel> { } @action - async fetch(id: string, options?: Object = {}): Promise<*> { + async fetch(id: string, options: Object = {}): Promise<*> { if (!this.actions.includes("info")) { throw new Error(`Cannot fetch ${this.modelName}`); } diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index ff1aa120e..1ddada7ec 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -1,15 +1,6 @@ // @flow import invariant from "invariant"; -import { - without, - map, - find, - orderBy, - filter, - compact, - omitBy, - uniq, -} from "lodash"; +import { find, orderBy, filter, compact, omitBy } from "lodash"; import { observable, action, computed, runInAction } from "mobx"; import naturalSort from "shared/utils/naturalSort"; import BaseStore from "stores/BaseStore"; @@ -23,7 +14,6 @@ type ImportOptions = { }; export default class DocumentsStore extends BaseStore<Document> { - @observable recentlyViewedIds: string[] = []; @observable searchCache: Map<string, SearchResult[]> = new Map(); @observable starredIds: Map<string, boolean> = new Map(); @observable backlinks: Map<string, string[]> = new Map(); @@ -50,8 +40,8 @@ export default class DocumentsStore extends BaseStore<Document> { @computed get recentlyViewed(): Document[] { return orderBy( - compact(this.recentlyViewedIds.map((id) => this.data.get(id))), - "updatedAt", + filter(this.all, (d) => d.lastViewedAt), + "lastViewedAt", "desc" ); } @@ -299,15 +289,7 @@ export default class DocumentsStore extends BaseStore<Document> { @action fetchRecentlyViewed = async (options: ?PaginationParams): Promise<*> => { - const data = await this.fetchNamedPage("viewed", options); - - runInAction("DocumentsStore#fetchRecentlyViewed", () => { - // $FlowFixMe - this.recentlyViewedIds.replace( - uniq(this.recentlyViewedIds.concat(map(data, "id"))) - ); - }); - return data; + return this.fetchNamedPage("viewed", options); }; @action @@ -330,12 +312,25 @@ export default class DocumentsStore extends BaseStore<Document> { return this.fetchNamedPage("list", options); }; + @action + searchTitles = async (query: string, options: PaginationParams = {}) => { + const res = await client.get("/documents.search_titles", { + query, + ...options, + }); + invariant(res && res.data, "Search response should be available"); + + // add the documents and associated policies to the store + res.data.forEach(this.add); + this.addPolicies(res.policies); + return res.data; + }; + @action search = async ( query: string, options: PaginationParams = {} ): Promise<SearchResult[]> => { - // $FlowFixMe const compactedOptions = omitBy(options, (o) => !o); const res = await client.get("/documents.search", { ...compactedOptions, @@ -399,7 +394,7 @@ export default class DocumentsStore extends BaseStore<Document> { @action fetch = async ( id: string, - options?: FetchOptions = {} + options: FetchOptions = {} ): Promise<?Document> => { if (!options.prefetch) this.isFetching = true; @@ -482,7 +477,7 @@ export default class DocumentsStore extends BaseStore<Document> { { key: "title", value: title }, { key: "publish", value: options.publish }, { key: "file", value: file }, - ].map((info) => { + ].forEach((info) => { if (typeof info.value === "string" && info.value) { formData.append(info.key, info.value); } @@ -541,10 +536,6 @@ export default class DocumentsStore extends BaseStore<Document> { async delete(document: Document) { await super.delete(document); - runInAction(() => { - this.recentlyViewedIds = without(this.recentlyViewedIds, document.id); - }); - // check to see if we have any shares related to this document already // loaded in local state. If so we can go ahead and remove those too. const share = this.rootStore.shares.getByDocumentId(document.id); diff --git a/app/utils/routeHelpers.js b/app/utils/routeHelpers.js index 1d7c402a2..145bb2834 100644 --- a/app/utils/routeHelpers.js +++ b/app/utils/routeHelpers.js @@ -63,14 +63,22 @@ export function newDocumentUrl( return `/collections/${collectionId}/new?${queryString.stringify(params)}`; } -export function searchUrl(query?: string, collectionId?: string): string { - let route = "/search"; - if (query) route += `/${encodeURIComponent(query)}`; - - if (collectionId) { - route += `?collectionId=${collectionId}`; +export function searchUrl( + query?: string, + params?: { + collectionId?: string, + ref?: string, } - return route; +): string { + let search = queryString.stringify(params); + let route = "/search"; + + if (query) { + route += `/${encodeURIComponent(query)}`; + } + + search = search ? `?${search}` : ""; + return `${route}${search}`; } export function notFoundUrl(): string { diff --git a/package.json b/package.json index 6ef02a6d0..3947fb109 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "react-portal": "^4.0.0", "react-router-dom": "^5.1.2", "react-waypoint": "^9.0.2", - "rich-markdown-editor": "^11.0.0-4", + "rich-markdown-editor": "^11.0.0-9", "semver": "^7.3.2", "sequelize": "^6.3.4", "sequelize-cli": "^6.2.0", @@ -195,4 +195,4 @@ "js-yaml": "^3.13.1" }, "version": "0.47.1" -} +} \ No newline at end of file diff --git a/server/api/documents.js b/server/api/documents.js index 6795d9164..44e54b2a2 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -11,6 +11,7 @@ import { Document, Event, Revision, + SearchQuery, Share, Star, User, @@ -98,10 +99,12 @@ router.post("documents.list", auth(), pagination(), async (ctx) => { // add the users starred state to the response by default const starredScope = { method: ["withStarred", user.id] }; const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", starredScope, - collectionScope + collectionScope, + viewScope ).findAll({ where, order: [[sort, direction]], @@ -137,10 +140,12 @@ router.post("documents.pinned", auth(), pagination(), async (ctx) => { const starredScope = { method: ["withStarred", user.id] }; const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", starredScope, - collectionScope + collectionScope, + viewScope ).findAll({ where: { teamId: user.teamId, @@ -176,9 +181,11 @@ router.post("documents.archived", auth(), pagination(), async (ctx) => { const collectionIds = await user.collectionIds(); const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", - collectionScope + collectionScope, + viewScope ).findAll({ where: { teamId: user.teamId, @@ -214,7 +221,8 @@ router.post("documents.deleted", auth(), pagination(), async (ctx) => { const collectionIds = await user.collectionIds({ paranoid: false }); const collectionScope = { method: ["withCollection", user.id] }; - const documents = await Document.scope(collectionScope).findAll({ + const viewScope = { method: ["withViews", user.id] }; + const documents = await Document.scope(collectionScope, viewScope).findAll({ where: { teamId: user.teamId, collectionId: collectionIds, @@ -276,7 +284,11 @@ router.post("documents.viewed", auth(), pagination(), async (ctx) => { limit: ctx.state.pagination.limit, }); - const documents = views.map((view) => view.document); + const documents = views.map((view) => { + const document = view.document; + document.views = [view]; + return document; + }); const data = await Promise.all( documents.map((document) => presentDocument(document)) ); @@ -349,9 +361,11 @@ router.post("documents.drafts", auth(), pagination(), async (ctx) => { const collectionIds = await user.collectionIds(); const collectionScope = { method: ["withCollection", user.id] }; + const viewScope = { method: ["withViews", user.id] }; const documents = await Document.scope( "defaultScope", - collectionScope + collectionScope, + viewScope ).findAll({ where: { userId: user.id, @@ -537,6 +551,52 @@ router.post("documents.restore", auth(), async (ctx) => { }; }); +router.post("documents.search_titles", auth(), pagination(), async (ctx) => { + const { query } = ctx.body; + const { offset, limit } = ctx.state.pagination; + const user = ctx.state.user; + ctx.assertPresent(query, "query is required"); + + const collectionIds = await user.collectionIds(); + + const documents = await Document.scope( + { + method: ["withViews", user.id], + }, + { + method: ["withCollection", user.id], + } + ).findAll({ + where: { + title: { + [Op.iLike]: `%${query}%`, + }, + collectionId: collectionIds, + archivedAt: { + [Op.eq]: null, + }, + }, + order: [["updatedAt", "DESC"]], + include: [ + { model: User, as: "createdBy", paranoid: false }, + { model: User, as: "updatedBy", paranoid: false }, + ], + offset, + limit, + }); + + const policies = presentPolicies(user, documents); + const data = await Promise.all( + documents.map((document) => presentDocument(document)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + policies, + }; +}); + router.post("documents.search", auth(), pagination(), async (ctx) => { const { query, @@ -573,7 +633,7 @@ router.post("documents.search", auth(), pagination(), async (ctx) => { ); } - const results = await Document.searchForUser(user, query, { + const { results, totalCount } = await Document.searchForUser(user, query, { includeArchived: includeArchived === "true", includeDrafts: includeDrafts === "true", collaboratorIds, @@ -591,6 +651,18 @@ router.post("documents.search", auth(), pagination(), async (ctx) => { }) ); + // When requesting subsequent pages of search results we don't want to record + // duplicate search query records + if (offset === 0) { + SearchQuery.create({ + userId: user.id, + teamId: user.teamId, + source: ctx.state.authType, + query, + results: totalCount, + }); + } + const policies = presentPolicies(user, documents); ctx.body = { diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 4d0792537..176215d8e 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -8,6 +8,7 @@ import { Revision, Backlink, CollectionUser, + SearchQuery, } from "../models"; import { buildShare, @@ -627,6 +628,56 @@ describe("#documents.drafts", () => { }); }); +describe("#documents.search_titles", () => { + it("should return case insensitive results for partial query", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + }); + + const res = await server.post("/api/documents.search_titles", { + body: { token: user.getJwtToken(), query: "SECRET" }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it("should not include archived or deleted documents", async () => { + const user = await buildUser(); + await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + archivedAt: new Date(), + }); + + await buildDocument({ + userId: user.id, + teamId: user.teamId, + title: "Super secret", + deletedAt: new Date(), + }); + + const res = await server.post("/api/documents.search_titles", { + body: { token: user.getJwtToken(), query: "SECRET" }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it("should require authentication", async () => { + const res = await server.post("/api/documents.search_titles"); + expect(res.status).toEqual(401); + }); +}); + describe("#documents.search", () => { it("should return results", async () => { const { user } = await seed(); @@ -954,6 +1005,25 @@ describe("#documents.search", () => { expect(res.status).toEqual(401); expect(body).toMatchSnapshot(); }); + + it("should save search term, hits and source", async (done) => { + const { user } = await seed(); + await server.post("/api/documents.search", { + body: { token: user.getJwtToken(), query: "my term" }, + }); + + // setTimeout is needed here because SearchQuery is saved asynchronously + // in order to not slow down the response time. + setTimeout(async () => { + const searchQuery = await SearchQuery.findAll({ + where: { query: "my term" }, + }); + expect(searchQuery.length).toBe(1); + expect(searchQuery[0].results).toBe(0); + expect(searchQuery[0].source).toBe("app"); + done(); + }, 100); + }); }); describe("#documents.archived", () => { diff --git a/server/api/hooks.js b/server/api/hooks.js index 7dacc2550..0616b920d 100644 --- a/server/api/hooks.js +++ b/server/api/hooks.js @@ -2,7 +2,14 @@ import Router from "koa-router"; import { escapeRegExp } from "lodash"; import { AuthenticationError, InvalidRequestError } from "../errors"; -import { Authentication, Document, User, Team, Collection } from "../models"; +import { + Authentication, + Document, + User, + Team, + Collection, + SearchQuery, +} from "../models"; import { presentSlackAttachment } from "../presenters"; import * as Slack from "../slack"; const router = new Router(); @@ -146,10 +153,18 @@ router.post("hooks.slack", async (ctx) => { const options = { limit: 5, }; - const results = user + const { results, totalCount } = user ? await Document.searchForUser(user, text, options) : await Document.searchForTeam(team, text, options); + SearchQuery.create({ + userId: user ? user.id : null, + teamId: team.id, + source: "slack", + query: text, + results: totalCount, + }); + if (results.length) { const attachments = []; for (const result of results) { diff --git a/server/api/hooks.test.js b/server/api/hooks.test.js index 7e643b1c4..10873f5f7 100644 --- a/server/api/hooks.test.js +++ b/server/api/hooks.test.js @@ -1,7 +1,7 @@ /* eslint-disable flowtype/require-valid-file-annotation */ import TestServer from "fetch-test-server"; import app from "../app"; -import { Authentication } from "../models"; +import { Authentication, SearchQuery } from "../models"; import * as Slack from "../slack"; import { buildDocument } from "../test/factories"; import { flushdb, seed } from "../test/support"; @@ -132,6 +132,30 @@ describe("#hooks.slack", () => { ); }); + it("should save search term, hits and source", async (done) => { + const { user, team } = await seed(); + await server.post("/api/hooks.slack", { + body: { + token: process.env.SLACK_VERIFICATION_TOKEN, + user_id: user.serviceId, + team_id: team.slackId, + text: "contains", + }, + }); + + // setTimeout is needed here because SearchQuery is saved asynchronously + // in order to not slow down the response time. + setTimeout(async () => { + const searchQuery = await SearchQuery.findAll({ + where: { query: "contains" }, + }); + expect(searchQuery.length).toBe(1); + expect(searchQuery[0].results).toBe(0); + expect(searchQuery[0].source).toBe("slack"); + done(); + }, 100); + }); + it("should respond with help content for help keyword", async () => { const { user, team } = await seed(); const res = await server.post("/api/hooks.slack", { diff --git a/server/commands/documentImporter.js b/server/commands/documentImporter.js index efa615c48..4410914f7 100644 --- a/server/commands/documentImporter.js +++ b/server/commands/documentImporter.js @@ -5,6 +5,7 @@ import mammoth from "mammoth"; import TurndownService from "turndown"; import uuid from "uuid"; import parseTitle from "../../shared/utils/parseTitle"; +import { InvalidRequestError } from "../errors"; import { Attachment, Event, User } from "../models"; import dataURItoBuffer from "../utils/dataURItoBuffer"; import parseImages from "../utils/parseImages"; @@ -66,6 +67,9 @@ export default async function documentImporter({ ip: string, }): Promise<{ text: string, title: string }> { const fileInfo = importMapping.filter((item) => item.type === file.type)[0]; + if (!fileInfo) { + throw new InvalidRequestError(`File type ${file.type} not supported`); + } let title = file.name.replace(/\.[^/.]+$/, ""); let text = await fileInfo.getMarkdown(file); diff --git a/server/commands/documentImporter.test.js b/server/commands/documentImporter.test.js index 2cac52725..66714eeea 100644 --- a/server/commands/documentImporter.test.js +++ b/server/commands/documentImporter.test.js @@ -74,4 +74,27 @@ describe("documentImporter", () => { expect(response.text).toContain("This is a test paragraph"); expect(response.title).toEqual("Heading 1"); }); + + it("should error with unknown file type", async () => { + const user = await buildUser(); + const name = "markdown.md"; + const file = new File({ + name, + type: "executable/zip", + path: path.resolve(__dirname, "..", "test", "fixtures", name), + }); + + let error; + try { + await documentImporter({ + user, + file, + ip, + }); + } catch (err) { + error = err.message; + } + + expect(error).toEqual("File type executable/zip not supported"); + }); }); diff --git a/server/middlewares/authentication.js b/server/middlewares/authentication.js index 8f923faeb..b3fc3635d 100644 --- a/server/middlewares/authentication.js +++ b/server/middlewares/authentication.js @@ -2,14 +2,17 @@ import addMinutes from "date-fns/add_minutes"; import addMonths from "date-fns/add_months"; import JWT from "jsonwebtoken"; -import { type Context } from "koa"; import { AuthenticationError, UserSuspendedError } from "../errors"; -import { User, ApiKey } from "../models"; +import { User, Team, ApiKey } from "../models"; +import type { ContextWithState } from "../types"; import { getCookieDomain } from "../utils/domains"; import { getUserForJWT } from "../utils/jwt"; export default function auth(options?: { required?: boolean } = {}) { - return async function authMiddleware(ctx: Context, next: () => Promise<*>) { + return async function authMiddleware( + ctx: ContextWithState, + next: () => Promise<mixed> + ) { let token; const authorizationHeader = ctx.request.get("authorization"); @@ -27,7 +30,6 @@ export default function auth(options?: { required?: boolean } = {}) { `Bad Authorization header format. Format is "Authorization: Bearer <token>"` ); } - // $FlowFixMe } else if (ctx.body && ctx.body.token) { token = ctx.body.token; } else if (ctx.request.query.token) { @@ -43,7 +45,8 @@ export default function auth(options?: { required?: boolean } = {}) { let user; if (token) { if (String(token).match(/^[\w]{38}$/)) { - // API key + ctx.state.authType = "api"; + let apiKey; try { apiKey = await ApiKey.findOne({ @@ -51,18 +54,22 @@ export default function auth(options?: { required?: boolean } = {}) { secret: token, }, }); - } catch (e) { + } catch (err) { throw new AuthenticationError("Invalid API key"); } - if (!apiKey) throw new AuthenticationError("Invalid API key"); + if (!apiKey) { + throw new AuthenticationError("Invalid API key"); + } user = await User.findByPk(apiKey.userId); - if (!user) throw new AuthenticationError("Invalid API key"); + if (!user) { + throw new AuthenticationError("Invalid API key"); + } } else { - /* $FlowFixMeNowPlease This comment suppresses an error found when upgrading - * flow-bin@0.104.0. To view the error, delete this comment and run Flow. */ - user = await getUserForJWT(token); + ctx.state.authType = "app"; + + user = await getUserForJWT(String(token)); } if (user.isSuspended) { @@ -76,21 +83,16 @@ export default function auth(options?: { required?: boolean } = {}) { // not awaiting the promise here so that the request is not blocked user.updateActiveAt(ctx.request.ip); - /* $FlowFixMeNowPlease This comment suppresses an error found when upgrading - * flow-bin@0.104.0. To view the error, delete this comment and run Flow. */ - ctx.state.token = token; - - /* $FlowFixMeNowPlease This comment suppresses an error found when upgrading - * flow-bin@0.104.0. To view the error, delete this comment and run Flow. */ + ctx.state.token = String(token); ctx.state.user = user; - if (!ctx.cache) ctx.cache = {}; - - /* $FlowFixMeNowPlease This comment suppresses an error found when upgrading - * flow-bin@0.104.0. To view the error, delete this comment and run Flow. */ - ctx.cache[user.id] = user; } - ctx.signIn = async (user, team, service, isFirstSignin = false) => { + ctx.signIn = async ( + user: User, + team: Team, + service, + isFirstSignin = false + ) => { if (user.isSuspended) { return ctx.redirect("/?notice=suspended"); } diff --git a/server/migrations/20200915010511-create-search-queries.js b/server/migrations/20200915010511-create-search-queries.js new file mode 100644 index 000000000..ac5e29d09 --- /dev/null +++ b/server/migrations/20200915010511-create-search-queries.js @@ -0,0 +1,43 @@ +"use strict"; +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("search_queries", { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + }, + userId: { + type: Sequelize.UUID, + references: { + model: "users", + }, + }, + teamId: { + type: Sequelize.UUID, + references: { + model: "teams", + }, + }, + source: { + type: Sequelize.ENUM("slack", "app", "api"), + allowNull: false, + }, + query: { + type: Sequelize.STRING, + allowNull: false, + }, + results: { + type: Sequelize.INTEGER, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + }, + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("search_queries"); + }, +}; diff --git a/server/migrations/20200926204620-add-missing-indexes.js b/server/migrations/20200926204620-add-missing-indexes.js new file mode 100644 index 000000000..3e730d266 --- /dev/null +++ b/server/migrations/20200926204620-add-missing-indexes.js @@ -0,0 +1,19 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addIndex("search_queries", ["teamId"]); + await queryInterface.addIndex("search_queries", ["userId"]); + await queryInterface.addIndex("search_queries", ["createdAt"]); + + await queryInterface.addIndex("users", ["teamId"]); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeIndex("search_queries", ["teamId"]); + await queryInterface.removeIndex("search_queries", ["userId"]); + await queryInterface.removeIndex("search_queries", ["createdAt"]); + + await queryInterface.removeIndex("users", ["teamId"]); + } +}; diff --git a/server/models/Document.js b/server/models/Document.js index e6f222e20..a49328712 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -207,11 +207,15 @@ Document.associate = (models) => { { model: models.User, as: "updatedBy", paranoid: false }, ], }); - Document.addScope("withViews", (userId) => ({ - include: [ - { model: models.View, as: "views", where: { userId }, required: false }, - ], - })); + Document.addScope("withViews", (userId) => { + if (!userId) return {}; + + return { + include: [ + { model: models.View, as: "views", where: { userId }, required: false }, + ], + }; + }); Document.addScope("withStarred", (userId) => ({ include: [ { model: models.Star, as: "starred", where: { userId }, required: false }, @@ -222,9 +226,15 @@ Document.associate = (models) => { Document.findByPk = async function (id, options = {}) { // allow default preloading of collection membership if `userId` is passed in find options // almost every endpoint needs the collection membership to determine policy permissions. - const scope = this.scope("withUnpublished", { - method: ["withCollection", options.userId], - }); + const scope = this.scope( + "withUnpublished", + { + method: ["withCollection", options.userId], + }, + { + method: ["withViews", options.userId], + } + ); if (isUUID(id)) { return scope.findOne({ @@ -241,10 +251,13 @@ Document.findByPk = async function (id, options = {}) { } }; -type SearchResult = { - ranking: number, - context: string, - document: Document, +type SearchResponse = { + results: { + ranking: number, + context: string, + document: Document, + }[], + totalCount: number, }; type SearchOptions = { @@ -267,7 +280,7 @@ Document.searchForTeam = async ( team, query, options: SearchOptions = {} -): Promise<SearchResult[]> => { +): Promise<SearchResponse> => { const limit = options.limit || 15; const offset = options.offset || 0; const wildcardQuery = `${escape(query)}:*`; @@ -275,21 +288,25 @@ Document.searchForTeam = async ( // If the team has access no public collections then shortcircuit the rest of this if (!collectionIds.length) { - return []; + return { results: [], totalCount: 0 }; } // Build the SQL query to get documentIds, ranking, and search term context - const sql = ` + const whereClause = ` + "searchVector" @@ to_tsquery('english', :query) AND + "teamId" = :teamId AND + "collectionId" IN(:collectionIds) AND + "deletedAt" IS NULL AND + "publishedAt" IS NOT NULL + `; + + const selectSql = ` SELECT id, ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext" FROM documents - WHERE "searchVector" @@ to_tsquery('english', :query) AND - "teamId" = :teamId AND - "collectionId" IN(:collectionIds) AND - "deletedAt" IS NULL AND - "publishedAt" IS NOT NULL + WHERE ${whereClause} ORDER BY "searchRanking" DESC, "updatedAt" DESC @@ -297,17 +314,34 @@ Document.searchForTeam = async ( OFFSET :offset; `; - const results = await sequelize.query(sql, { + const countSql = ` + SELECT COUNT(id) + FROM documents + WHERE ${whereClause} + `; + + const queryReplacements = { + teamId: team.id, + query: wildcardQuery, + collectionIds, + }; + + const resultsQuery = sequelize.query(selectSql, { type: sequelize.QueryTypes.SELECT, replacements: { - teamId: team.id, - query: wildcardQuery, + ...queryReplacements, limit, offset, - collectionIds, }, }); + const countQuery = sequelize.query(countSql, { + type: sequelize.QueryTypes.SELECT, + replacements: queryReplacements, + }); + + const [results, [{ count }]] = await Promise.all([resultsQuery, countQuery]); + // Final query to get associated document data const documents = await Document.findAll({ where: { @@ -320,20 +354,23 @@ Document.searchForTeam = async ( ], }); - return map(results, (result) => ({ - ranking: result.searchRanking, - context: removeMarkdown(unescape(result.searchContext), { - stripHTML: false, - }), - document: find(documents, { id: result.id }), - })); + return { + results: map(results, (result) => ({ + ranking: result.searchRanking, + context: removeMarkdown(unescape(result.searchContext), { + stripHTML: false, + }), + document: find(documents, { id: result.id }), + })), + totalCount: count, + }; }; Document.searchForUser = async ( user, query, options: SearchOptions = {} -): Promise<SearchResult[]> => { +): Promise<SearchResponse> => { const limit = options.limit || 15; const offset = options.offset || 0; const wildcardQuery = `${escape(query)}:*`; @@ -350,7 +387,7 @@ Document.searchForUser = async ( // If the user has access to no collections then shortcircuit the rest of this if (!collectionIds.length) { - return []; + return { results: [], totalCount: 0 }; } let dateFilter; @@ -359,13 +396,8 @@ Document.searchForUser = async ( } // Build the SQL query to get documentIds, ranking, and search term context - const sql = ` - SELECT - id, - ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", - ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext" - FROM documents - WHERE "searchVector" @@ to_tsquery('english', :query) AND + const whereClause = ` + "searchVector" @@ to_tsquery('english', :query) AND "teamId" = :teamId AND "collectionId" IN(:collectionIds) AND ${ @@ -383,27 +415,52 @@ Document.searchForUser = async ( ? '("publishedAt" IS NOT NULL OR "createdById" = :userId)' : '"publishedAt" IS NOT NULL' } + `; + + const selectSql = ` + SELECT + id, + ts_rank(documents."searchVector", to_tsquery('english', :query)) as "searchRanking", + ts_headline('english', "text", to_tsquery('english', :query), 'MaxFragments=1, MinWords=20, MaxWords=30') as "searchContext" + FROM documents + WHERE ${whereClause} ORDER BY "searchRanking" DESC, "updatedAt" DESC LIMIT :limit OFFSET :offset; -`; + `; - const results = await sequelize.query(sql, { + const countSql = ` + SELECT COUNT(id) + FROM documents + WHERE ${whereClause} + `; + + const queryReplacements = { + teamId: user.teamId, + userId: user.id, + collaboratorIds: options.collaboratorIds, + query: wildcardQuery, + collectionIds, + dateFilter, + }; + + const resultsQuery = sequelize.query(selectSql, { type: sequelize.QueryTypes.SELECT, replacements: { - teamId: user.teamId, - userId: user.id, - collaboratorIds: options.collaboratorIds, - query: wildcardQuery, + ...queryReplacements, limit, offset, - collectionIds, - dateFilter, }, }); + const countQuery = sequelize.query(countSql, { + type: sequelize.QueryTypes.SELECT, + replacements: queryReplacements, + }); + + const [results, [{ count }]] = await Promise.all([resultsQuery, countQuery]); // Final query to get associated document data const documents = await Document.scope( { @@ -422,13 +479,16 @@ Document.searchForUser = async ( ], }); - return map(results, (result) => ({ - ranking: result.searchRanking, - context: removeMarkdown(unescape(result.searchContext), { - stripHTML: false, - }), - document: find(documents, { id: result.id }), - })); + return { + results: map(results, (result) => ({ + ranking: result.searchRanking, + context: removeMarkdown(unescape(result.searchContext), { + stripHTML: false, + }), + document: find(documents, { id: result.id }), + })), + totalCount: count, + }; }; // Hooks diff --git a/server/models/Document.test.js b/server/models/Document.test.js index ae48995e4..3befac7c5 100644 --- a/server/models/Document.test.js +++ b/server/models/Document.test.js @@ -201,7 +201,7 @@ describe("#searchForTeam", () => { title: "test", }); - const results = await Document.searchForTeam(team, "test"); + const { results } = await Document.searchForTeam(team, "test"); expect(results.length).toBe(1); expect(results[0].document.id).toBe(document.id); }); @@ -218,15 +218,85 @@ describe("#searchForTeam", () => { title: "test", }); - const results = await Document.searchForTeam(team, "test"); + const { results } = await Document.searchForTeam(team, "test"); expect(results.length).toBe(0); }); test("should handle no collections", async () => { const team = await buildTeam(); - const results = await Document.searchForTeam(team, "test"); + const { results } = await Document.searchForTeam(team, "test"); expect(results.length).toBe(0); }); + + test("should return the total count of search results", async () => { + const team = await buildTeam(); + const collection = await buildCollection({ teamId: team.id }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test number 1", + }); + await buildDocument({ + teamId: team.id, + collectionId: collection.id, + title: "test number 2", + }); + + const { totalCount } = await Document.searchForTeam(team, "test"); + expect(totalCount).toBe("2"); + }); +}); + +describe("#searchForUser", () => { + test("should return search results from collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + const document = await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test", + }); + + const { results } = await Document.searchForUser(user, "test"); + expect(results.length).toBe(1); + expect(results[0].document.id).toBe(document.id); + }); + + test("should handle no collections", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const { results } = await Document.searchForUser(user, "test"); + expect(results.length).toBe(0); + }); + + test("should return the total count of search results", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const collection = await buildCollection({ + userId: user.id, + teamId: team.id, + }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test number 1", + }); + await buildDocument({ + userId: user.id, + teamId: team.id, + collectionId: collection.id, + title: "test number 2", + }); + + const { totalCount } = await Document.searchForUser(user, "test"); + expect(totalCount).toBe("2"); + }); }); describe("#delete", () => { diff --git a/server/models/SearchQuery.js b/server/models/SearchQuery.js new file mode 100644 index 000000000..cd7258d35 --- /dev/null +++ b/server/models/SearchQuery.js @@ -0,0 +1,42 @@ +// @flow +import { DataTypes, sequelize } from "../sequelize"; + +const SearchQuery = sequelize.define( + "search_queries", + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + source: { + type: DataTypes.ENUM("slack", "app", "api"), + allowNull: false, + }, + query: { + type: DataTypes.STRING, + allowNull: false, + }, + results: { + type: DataTypes.NUMBER, + allowNull: false, + }, + }, + { + timestamps: true, + updatedAt: false, + } +); + +SearchQuery.associate = (models) => { + SearchQuery.belongsTo(models.User, { + as: "user", + foreignKey: "userId", + }); + SearchQuery.belongsTo(models.Team, { + as: "team", + foreignKey: "teamId", + }); +}; + +export default SearchQuery; diff --git a/server/models/View.js b/server/models/View.js index b0d88e485..7607aebc8 100644 --- a/server/models/View.js +++ b/server/models/View.js @@ -2,7 +2,7 @@ import subMilliseconds from "date-fns/sub_milliseconds"; import { USER_PRESENCE_INTERVAL } from "../../shared/constants"; import { User } from "../models"; -import { Op, DataTypes, sequelize } from "../sequelize"; +import { DataTypes, Op, sequelize } from "../sequelize"; const View = sequelize.define( "view", diff --git a/server/models/index.js b/server/models/index.js index 16e8fe854..c5f2da6ac 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -14,6 +14,7 @@ import Integration from "./Integration"; import Notification from "./Notification"; import NotificationSetting from "./NotificationSetting"; import Revision from "./Revision"; +import SearchQuery from "./SearchQuery"; import Share from "./Share"; import Star from "./Star"; import Team from "./Team"; @@ -36,6 +37,7 @@ const models = { Notification, NotificationSetting, Revision, + SearchQuery, Share, Star, Team, @@ -66,6 +68,7 @@ export { Notification, NotificationSetting, Revision, + SearchQuery, Share, Star, Team, diff --git a/server/presenters/document.js b/server/presenters/document.js index 969555abe..c459db85f 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -1,6 +1,6 @@ // @flow import { takeRight } from "lodash"; -import { User, Document, Attachment } from "../models"; +import { Attachment, Document, User } from "../models"; import { getSignedImageUrl } from "../utils/s3"; import presentUser from "./user"; @@ -62,8 +62,13 @@ export default async function present(document: Document, options: ?Options) { pinned: undefined, collectionId: undefined, parentDocumentId: undefined, + lastViewedAt: undefined, }; + if (!!document.views && document.views.length > 0) { + data.lastViewedAt = document.views[0].updatedAt; + } + if (!options.isPublic) { data.pinned = !!document.pinnedById; data.collectionId = document.collectionId; diff --git a/server/routes.js b/server/routes.js index ae634a949..537b5712c 100644 --- a/server/routes.js +++ b/server/routes.js @@ -37,6 +37,22 @@ const readIndexFile = async (ctx) => { }); }; +const renderApp = async (ctx, next) => { + if (ctx.request.path === "/realtime/") { + return next(); + } + + const page = await readIndexFile(ctx); + const env = ` + window.env = ${JSON.stringify(environment)}; + `; + ctx.body = page + .toString() + .replace(/\/\/inject-env\/\//g, env) + .replace(/\/\/inject-sentry-dsn\/\//g, process.env.SENTRY_DSN || "") + .replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || ""); +}; + // serve static assets koa.use( serve(path.resolve(__dirname, "../../public"), { @@ -65,23 +81,14 @@ router.get("/opensearch.xml", (ctx) => { ctx.body = opensearchResponse(); }); -// catch all for application -router.get("*", async (ctx, next) => { - if (ctx.request.path === "/realtime/") { - return next(); - } - - const page = await readIndexFile(ctx); - const env = ` - window.env = ${JSON.stringify(environment)}; - `; - ctx.body = page - .toString() - .replace(/\/\/inject-env\/\//g, env) - .replace(/\/\/inject-sentry-dsn\/\//g, process.env.SENTRY_DSN || "") - .replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || ""); +router.get("/share/*", (ctx, next) => { + ctx.remove("X-Frame-Options"); + return renderApp(ctx, next); }); +// catch all for application +router.get("*", renderApp); + // middleware koa.use(apexRedirect()); koa.use(router.routes()); diff --git a/server/static/index.html b/server/static/index.html index a0982d8f4..d5ef94d9e 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -62,7 +62,7 @@ }); } - if (window.localStorage.getItem("theme") === "dark") { + if (window.localStorage && window.localStorage.getItem("theme") === "dark") { window.document.querySelector("#root").style.background = "#111319"; } </script> diff --git a/server/types.js b/server/types.js new file mode 100644 index 000000000..5fde708a1 --- /dev/null +++ b/server/types.js @@ -0,0 +1,12 @@ +// @flow +import { type Context } from "koa"; +import { User } from "./models"; + +export type ContextWithState = {| + ...$Exact<Context>, + state: { + user: User, + token: string, + authType: "app" | "api", + }, +|}; diff --git a/server/utils/jwt.js b/server/utils/jwt.js index 925fdaff6..d1bbfa2bf 100644 --- a/server/utils/jwt.js +++ b/server/utils/jwt.js @@ -18,7 +18,7 @@ function getJWTPayload(token) { return payload; } -export async function getUserForJWT(token: string) { +export async function getUserForJWT(token: string): Promise<User> { const payload = getJWTPayload(token); const user = await User.findByPk(payload.id); @@ -31,7 +31,7 @@ export async function getUserForJWT(token: string) { return user; } -export async function getUserForEmailSigninToken(token: string) { +export async function getUserForEmailSigninToken(token: string): Promise<User> { const payload = getJWTPayload(token); // check the token is within it's expiration time diff --git a/shared/styles/theme.js b/shared/styles/theme.js index 5252f49be..fb0c458a8 100644 --- a/shared/styles/theme.js +++ b/shared/styles/theme.js @@ -26,6 +26,7 @@ const colors = { yellow: "#FBCA04", warmGrey: "#EDF2F7", + searchHighlight: "#FDEA9B", danger: "#ff476f", warning: "#f08a24", success: "#2f3336", @@ -138,6 +139,7 @@ export const light = { listItemHoverBackground: colors.warmGrey, + toolbarHoverBackground: colors.black, toolbarBackground: colors.lightBlack, toolbarInput: colors.white10, toolbarItem: colors.white, @@ -192,6 +194,7 @@ export const dark = { listItemHoverBackground: colors.black50, + toolbarHoverBackground: colors.slate, toolbarBackground: colors.white, toolbarInput: colors.black10, toolbarItem: colors.lightBlack, diff --git a/yarn.lock b/yarn.lock index 601b87b60..510194f28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9887,10 +9887,10 @@ retry-as-promised@^3.2.0: dependencies: any-promise "^1.3.0" -rich-markdown-editor@^11.0.0-4: - version "11.0.0-4" - resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-4.tgz#b65f5b03502d70a2b2bbea5c916c23b071f4bab6" - integrity sha512-+llzd8Plxzsc/jJ8RwtMSV5QIpxpZdM5nQejG/SLe/lfqHNOFNnIiOszSPERIcULLxsLdMT5Ajz+Yr5PXPicOQ== +rich-markdown-editor@^11.0.0-9: + version "11.0.0-9" + resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-11.0.0-9.tgz#a7a4bfa09fca3cdf3168027e11fd9af46c708680" + integrity sha512-B1q6VbRF/6yjHsYMQEXjgjwPJCSU3mNEmLGsJOF+PZACII5ojg8bV51jGd4W1rTvbIzqnLK4iPWlAbn+hrMtXw== dependencies: copy-to-clipboard "^3.0.8" lodash "^4.17.11"