1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,3 +8,4 @@ npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
.idea
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} /> ago
|
||||
deleted <Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} /> ago
|
||||
archived <Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
created <Time dateTime={updatedAt} /> ago
|
||||
created <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} /> ago
|
||||
published <Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
saved <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
updated <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
• <Modified highlight>Never viewed</Modified>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
• Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? "You" : updatedBy.name}
|
||||
@@ -115,6 +136,7 @@ function DocumentMeta({
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -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\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -48,17 +50,15 @@ class DocumentPreview extends React.Component<Props> {
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/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<Props> {
|
||||
context,
|
||||
} = this.props;
|
||||
|
||||
if (this.redirectTo) {
|
||||
return <Redirect to={this.redirectTo} push />;
|
||||
}
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
@@ -86,6 +90,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{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;
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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})`}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})`}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})`}
|
||||
/>
|
||||
|
||||
@@ -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})`}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -140,6 +140,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="collection"
|
||||
placeholder="Search in collection…"
|
||||
collectionId={match.params.id}
|
||||
/>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> {
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch />
|
||||
<InputSearch source="drafts" />
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,7 +49,7 @@ class Starred extends React.Component<Props> {
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch />
|
||||
<InputSearch source="starred" />
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
43
server/migrations/20200915010511-create-search-queries.js
Normal file
43
server/migrations/20200915010511-create-search-queries.js
Normal file
@@ -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");
|
||||
},
|
||||
};
|
||||
19
server/migrations/20200926204620-add-missing-indexes.js
Normal file
19
server/migrations/20200926204620-add-missing-indexes.js
Normal file
@@ -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"]);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
42
server/models/SearchQuery.js
Normal file
42
server/models/SearchQuery.js
Normal file
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
12
server/types.js
Normal file
12
server/types.js
Normal file
@@ -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",
|
||||
},
|
||||
|};
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user