feat: Separate title from body (#1216)

* first pass at updating all Time components each second

* fix a couple date variable typos

* use class style state management instead of hooks

* wip: Separate title from body

* address feedback

* test: Remove unused test

* feat: You in publishing info language
fix: Removal of secondary headings

* After much deliberation… a migration is needed for this to be reliable

* fix: Export to work with new title structure

* fix: Untitled

* fix: Consistent spacing of first editor node

* fix: Emoji in title handling

* fix: Time component not updating for new props

* chore: Add createdAt case

* fix: Conflict after merging new TOC

* PR feedback

* lint

* fix: Heading level adjustment

Co-authored-by: Taylor Lapeyre <taylorlapeyre@gmail.com>
This commit is contained in:
Tom Moor
2020-04-05 15:07:34 -07:00
committed by GitHub
parent a0e73bf4c2
commit 9338a54fe0
19 changed files with 241 additions and 145 deletions

View File

@ -152,7 +152,7 @@ class DocumentPreview extends React.Component<Props> {
{...rest} {...rest}
> >
<Heading> <Heading>
<Title text={document.title} highlight={highlight} /> <Title text={document.title || 'Untitled'} highlight={highlight} />
{!document.isDraft && {!document.isDraft &&
!document.isArchived && ( !document.isArchived && (
<Actions> <Actions>

View File

@ -113,7 +113,7 @@ const StyledEditor = styled(RichMarkdownEditor)`
visibility: hidden; visibility: hidden;
} }
} }
p:nth-child(2):last-child { p:nth-child(1):last-child {
${Placeholder} { ${Placeholder} {
visibility: visible; visibility: visible;
} }
@ -131,6 +131,15 @@ const StyledEditor = styled(RichMarkdownEditor)`
} }
} }
} }
h1:first-child,
h2:first-child,
h3:first-child,
h4:first-child,
h5:first-child,
h6:first-child {
margin-top: 0;
}
`; `;
/* /*

View File

@ -1,12 +1,13 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import { inject } from 'mobx-react'; import { inject, observer } from 'mobx-react';
import styled from 'styled-components'; import styled from 'styled-components';
import Document from 'models/Document'; import Document from 'models/Document';
import Flex from 'shared/components/Flex'; import Flex from 'shared/components/Flex';
import Time from 'shared/components/Time'; import Time from 'shared/components/Time';
import Breadcrumb from 'shared/components/Breadcrumb'; import Breadcrumb from 'shared/components/Breadcrumb';
import CollectionsStore from 'stores/CollectionsStore'; import CollectionsStore from 'stores/CollectionsStore';
import AuthStore from 'stores/AuthStore';
const Container = styled(Flex)` const Container = styled(Flex)`
color: ${props => props.theme.textTertiary}; color: ${props => props.theme.textTertiary};
@ -23,29 +24,33 @@ const Modified = styled.span`
type Props = { type Props = {
collections: CollectionsStore, collections: CollectionsStore,
auth: AuthStore,
showCollection?: boolean, showCollection?: boolean,
showPublished?: boolean, showPublished?: boolean,
document: Document, document: Document,
views?: number, children: React.Node,
}; };
function PublishingInfo({ function PublishingInfo({
auth,
collections, collections,
showPublished, showPublished,
showCollection, showCollection,
document, document,
children,
...rest
}: Props) { }: Props) {
const { const {
modifiedSinceViewed, modifiedSinceViewed,
updatedAt, updatedAt,
updatedBy, updatedBy,
createdAt,
publishedAt, publishedAt,
archivedAt, archivedAt,
deletedAt, deletedAt,
isDraft, isDraft,
} = document; } = document;
const neverUpdated = publishedAt === updatedAt;
let content; let content;
if (deletedAt) { if (deletedAt) {
@ -60,7 +65,13 @@ function PublishingInfo({
archived <Time dateTime={archivedAt} /> ago archived <Time dateTime={archivedAt} /> ago
</span> </span>
); );
} else if (publishedAt && (neverUpdated || showPublished)) { } else if (createdAt === updatedAt) {
content = (
<span>
created <Time dateTime={updatedAt} /> ago
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = ( content = (
<span> <span>
published <Time dateTime={publishedAt} /> ago published <Time dateTime={publishedAt} /> ago
@ -81,10 +92,11 @@ function PublishingInfo({
} }
const collection = collections.get(document.collectionId); const collection = collections.get(document.collectionId);
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
return ( return (
<Container align="center"> <Container align="center" {...rest}>
{updatedBy.name}&nbsp; {updatedByMe ? 'You' : updatedBy.name}&nbsp;
{content} {content}
{showCollection && {showCollection &&
collection && ( collection && (
@ -95,8 +107,9 @@ function PublishingInfo({
</strong> </strong>
</span> </span>
)} )}
{children}
</Container> </Container>
); );
} }
export default inject('collections')(PublishingInfo); export default inject('collections', 'auth')(observer(PublishingInfo));

View File

@ -40,18 +40,9 @@ export default class Document extends BaseModel {
shareUrl: ?string; shareUrl: ?string;
revision: number; revision: number;
constructor(data?: Object = {}, store: DocumentsStore) { get emoji() {
super(data, store); const { emoji } = parseTitle(this.title);
this.updateTitle(); return emoji;
}
@action
updateTitle() {
const { title, emoji } = parseTitle(this.text);
if (title) {
set(this, { title, emoji });
}
} }
@computed @computed
@ -61,15 +52,7 @@ export default class Document extends BaseModel {
@computed @computed
get isOnlyTitle(): boolean { get isOnlyTitle(): boolean {
const { title } = parseTitle(this.text); return !this.text.trim();
// find and extract title
const trimmedBody = this.text
.trim()
.replace(/^#/, '')
.trim();
return unescape(trimmedBody) === title;
} }
@computed @computed
@ -117,7 +100,6 @@ export default class Document extends BaseModel {
@action @action
updateFromJson = data => { updateFromJson = data => {
set(this, data); set(this, data);
this.updateTitle();
}; };
archive = () => { archive = () => {
@ -182,7 +164,6 @@ export default class Document extends BaseModel {
const isCreating = !this.id; const isCreating = !this.id;
this.isSaving = true; this.isSaving = true;
this.updateTitle();
try { try {
if (isCreating) { if (isCreating) {
@ -221,14 +202,17 @@ export default class Document extends BaseModel {
// Ensure the document is upto date with latest server contents // Ensure the document is upto date with latest server contents
await this.fetch(); await this.fetch();
const blob = new Blob([unescape(this.text)], { type: 'text/markdown' }); const body = unescape(this.text);
const blob = new Blob([`# ${this.title}\n\n${body}`], {
type: 'text/markdown',
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
// Firefox support requires the anchor tag be in the DOM to trigger the dl // Firefox support requires the anchor tag be in the DOM to trigger the dl
if (document.body) document.body.appendChild(a); if (document.body) document.body.appendChild(a);
a.href = url; a.href = url;
a.download = `${this.title}.md`; a.download = `${this.title || 'Untitled'}.md`;
a.click(); a.click();
}; };
} }

View File

@ -1,12 +0,0 @@
/* eslint-disable */
import stores from '../stores';
describe('Document model', () => {
test('should initialize with data', () => {
const document = stores.documents.add({
id: 123,
text: '# Onboarding\nSome body text',
});
expect(document.title).toBe('Onboarding');
});
});

View File

@ -43,6 +43,15 @@ export default function Contents({ document }: Props) {
[position] [position]
); );
// calculate the minimum heading level and adjust all the headings to make
// that the top-most. This prevents the contents from being weirdly indented
// if all of the headings in the document are level 3, for example.
const minHeading = headings.reduce(
(memo, heading) => (heading.level < memo ? heading.level : memo),
Infinity
);
const headingAdjustment = minHeading - 1;
return ( return (
<div> <div>
<Wrapper> <Wrapper>
@ -52,7 +61,7 @@ export default function Contents({ document }: Props) {
{headings.map(heading => ( {headings.map(heading => (
<ListItem <ListItem
key={heading.slug} key={heading.slug}
level={heading.level} level={heading.level - headingAdjustment}
active={activeSlug === heading.slug} active={activeSlug === heading.slug}
> >
<Link href={`#${heading.slug}`}>{heading.title}</Link> <Link href={`#${heading.slug}`}>{heading.title}</Link>

View File

@ -5,6 +5,7 @@ import styled from 'styled-components';
import breakpoint from 'styled-components-breakpoint'; import breakpoint from 'styled-components-breakpoint';
import { observable } from 'mobx'; import { observable } from 'mobx';
import { observer, inject } from 'mobx-react'; import { observer, inject } from 'mobx-react';
import { schema } from 'rich-markdown-editor';
import { Prompt, Route, withRouter } from 'react-router-dom'; import { Prompt, Route, withRouter } from 'react-router-dom';
import type { Location, RouterHistory } from 'react-router-dom'; import type { Location, RouterHistory } from 'react-router-dom';
import keydown from 'react-keydown'; import keydown from 'react-keydown';
@ -37,8 +38,6 @@ import AuthStore from 'stores/AuthStore';
import Document from 'models/Document'; import Document from 'models/Document';
import Revision from 'models/Revision'; import Revision from 'models/Revision';
import schema from '../schema';
let EditorImport; let EditorImport;
const AUTOSAVE_DELAY = 3000; const AUTOSAVE_DELAY = 3000;
const IS_DIRTY_DELAY = 500; const IS_DIRTY_DELAY = 500;
@ -75,9 +74,11 @@ class DocumentScene extends React.Component<Props> {
@observable isDirty: boolean = false; @observable isDirty: boolean = false;
@observable isEmpty: boolean = true; @observable isEmpty: boolean = true;
@observable moveModalOpen: boolean = false; @observable moveModalOpen: boolean = false;
@observable title: string;
constructor(props) { constructor(props) {
super(); super();
this.title = props.document.title;
this.loadEditor(); this.loadEditor();
} }
@ -168,13 +169,20 @@ class DocumentScene extends React.Component<Props> {
// get the latest version of the editor text value // get the latest version of the editor text value
const text = this.getEditorText ? this.getEditorText() : document.text; const text = this.getEditorText ? this.getEditorText() : document.text;
const title = this.title;
// prevent save before anything has been written (single hash is empty doc) // prevent save before anything has been written (single hash is empty doc)
if (text.trim() === '#') return; if (text.trim() === '' && title.trim === '') return;
// prevent autosave if nothing has changed // prevent autosave if nothing has changed
if (options.autosave && document.text.trim() === text.trim()) return; if (
options.autosave &&
document.text.trim() === text.trim() &&
document.title.trim() === title.trim()
)
return;
document.title = title;
document.text = text; document.text = text;
let isNew = !document.id; let isNew = !document.id;
@ -201,10 +209,12 @@ class DocumentScene extends React.Component<Props> {
updateIsDirty = () => { updateIsDirty = () => {
const { document } = this.props; const { document } = this.props;
const editorText = this.getEditorText().trim(); const editorText = this.getEditorText().trim();
const titleChanged = this.title !== document.title;
const bodyChanged = editorText !== document.text.trim();
// a single hash is a doc with just an empty title // a single hash is a doc with just an empty title
this.isEmpty = !editorText || editorText === '#'; this.isEmpty = (!editorText || editorText === '#') && !this.title;
this.isDirty = !!document && editorText !== document.text.trim(); this.isDirty = bodyChanged || titleChanged;
}; };
updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY); updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY);
@ -223,6 +233,12 @@ class DocumentScene extends React.Component<Props> {
this.autosave(); this.autosave();
}; };
onChangeTitle = event => {
this.title = event.target.value;
this.updateIsDirtyDebounced();
this.autosave();
};
goBack = () => { goBack = () => {
let url; let url;
if (this.props.document.url) { if (this.props.document.url) {
@ -245,7 +261,7 @@ class DocumentScene extends React.Component<Props> {
} = this.props; } = this.props;
const team = auth.team; const team = auth.team;
const Editor = this.editorComponent; const Editor = this.editorComponent;
const isShare = match.params.shareId; const isShare = !!match.params.shareId;
if (!Editor) { if (!Editor) {
return <Loading location={location} />; return <Loading location={location} />;
@ -334,13 +350,16 @@ class DocumentScene extends React.Component<Props> {
readOnly && <Contents document={revision || document} />} readOnly && <Contents document={revision || document} />}
<Editor <Editor
id={document.id} id={document.id}
isDraft={document.isDraft}
key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'} key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'}
title={revision ? revision.title : this.title}
document={document}
defaultValue={revision ? revision.text : document.text} defaultValue={revision ? revision.text : document.text}
pretitle={document.emoji}
disableEmbeds={disableEmbeds} disableEmbeds={disableEmbeds}
onImageUploadStart={this.onImageUploadStart} onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop} onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.props.onSearchLink} onSearchLink={this.props.onSearchLink}
onChangeTitle={this.onChangeTitle}
onChange={this.onChange} onChange={this.onChange}
onSave={this.onSave} onSave={this.onSave}
onPublish={this.onPublish} onPublish={this.onPublish}
@ -350,7 +369,6 @@ class DocumentScene extends React.Component<Props> {
schema={schema} schema={schema}
/> />
</Flex> </Flex>
{readOnly && {readOnly &&
!isShare && !isShare &&
!revision && ( !revision && (
@ -389,7 +407,6 @@ const MaxWidth = styled(Flex)`
${breakpoint('desktopLarge')` ${breakpoint('desktopLarge')`
max-width: calc(48px + 46em); max-width: calc(48px + 46em);
box-sizing:
`}; `};
`; `;

View File

@ -1,19 +1,32 @@
// @flow // @flow
import * as React from 'react'; import * as React from 'react';
import styled from 'styled-components';
import { inject, observer } from 'mobx-react';
import Editor from 'components/Editor'; import Editor from 'components/Editor';
import PublishingInfo from 'components/PublishingInfo';
import ClickablePadding from 'components/ClickablePadding'; import ClickablePadding from 'components/ClickablePadding';
import Flex from 'shared/components/Flex';
import parseTitle from 'shared/utils/parseTitle';
import ViewsStore from 'stores/ViewsStore';
import Document from 'models/Document';
import plugins from './plugins'; import plugins from './plugins';
type Props = {| type Props = {|
defaultValue?: string, onChangeTitle: (event: SyntheticInputEvent<>) => void,
title: string,
defaultValue: string,
document: Document,
views: ViewsStore,
isDraft: boolean,
readOnly?: boolean, readOnly?: boolean,
|}; |};
@observer
class DocumentEditor extends React.Component<Props> { class DocumentEditor extends React.Component<Props> {
editor: ?Editor; editor: ?Editor;
componentDidMount() { componentDidMount() {
if (!this.props.defaultValue) { if (this.props.title) {
setImmediate(this.focusAtStart); setImmediate(this.focusAtStart);
} }
} }
@ -30,22 +43,82 @@ class DocumentEditor extends React.Component<Props> {
} }
}; };
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
this.focusAtStart();
}
};
render() { render() {
const { readOnly } = this.props; const {
views,
document,
title,
onChangeTitle,
isDraft,
readOnly,
} = this.props;
const totalViews = views.countForDocument(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(
emoji && title.match(new RegExp(`^${emoji}\\s`))
);
return ( return (
<Flex column>
<Title
type="text"
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder="Start with a title…"
value={!title && readOnly ? 'Untitled' : title}
offsetLeft={startsWithEmojiAndSpace}
readOnly={readOnly}
autoFocus={!title}
/>
<Meta document={document}>
{totalViews && !isDraft ? (
<React.Fragment> <React.Fragment>
&nbsp;&middot; Viewed{' '}
{totalViews === 1 ? 'once' : `${totalViews} times`}
</React.Fragment>
) : null}
</Meta>
<Editor <Editor
ref={ref => (this.editor = ref)} ref={ref => (this.editor = ref)}
autoFocus={!this.props.defaultValue} autoFocus={title && !this.props.defaultValue}
placeholder="…the rest is up to you"
plugins={plugins} plugins={plugins}
grow grow
{...this.props} {...this.props}
/> />
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />} {!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
</React.Fragment> </Flex>
); );
} }
} }
export default DocumentEditor; const Meta = styled(PublishingInfo)`
margin: -12px 0 2em 0;
font-size: 14px;
`;
const Title = styled('input')`
line-height: 1.25;
margin-top: 1em;
margin-bottom: 0.5em;
margin-left: ${props => (props.offsetLeft ? '-1.2em' : 0)};
color: ${props => props.theme.text};
font-size: 2.25em;
font-weight: 500;
outline: none;
border: 0;
padding: 0;
&::placeholder {
color: ${props => props.theme.placeholder};
}
`;
export default inject('views')(DocumentEditor);

View File

@ -180,6 +180,7 @@ class Header extends React.Component<Props> {
<Status>Saving</Status> <Status>Saving</Status>
</Action> </Action>
)} )}
&nbsp;
<Collaborators <Collaborators
document={document} document={document}
currentUserId={auth.user ? auth.user.id : undefined} currentUserId={auth.user ? auth.user.id : undefined}
@ -280,7 +281,6 @@ class Header extends React.Component<Props> {
/> />
</Action> </Action>
)} )}
{!isEditing && ( {!isEditing && (
<React.Fragment> <React.Fragment>
<Separator /> <Separator />

View File

@ -1,31 +1,8 @@
// @flow // @flow
import { Node, Editor } from 'slate'; import { Editor } from 'slate';
import Placeholder from 'rich-markdown-editor/lib/plugins/Placeholder';
import isModKey from 'rich-markdown-editor/lib/lib/isModKey'; import isModKey from 'rich-markdown-editor/lib/lib/isModKey';
export default [ export default [
Placeholder({
placeholder: 'Start with a title…',
when: (editor: Editor, node: Node) => {
if (editor.readOnly) return false;
if (node.object !== 'block') return false;
if (node.type !== 'heading1') return false;
if (node.text !== '') return false;
if (editor.value.document.nodes.first() !== node) return false;
return true;
},
}),
Placeholder({
placeholder: '…the rest is your canvas',
when: (editor: Editor, node: Node) => {
if (editor.readOnly) return false;
if (node.object !== 'block') return false;
if (node.type !== 'paragraph') return false;
if (node.text !== '') return false;
if (editor.value.document.getDepth(node.key) !== 1) return false;
return true;
},
}),
{ {
onKeyDown(ev: SyntheticKeyboardEvent<>, editor: Editor, next: Function) { onKeyDown(ev: SyntheticKeyboardEvent<>, editor: Editor, next: Function) {
if (ev.key === 'p' && ev.shiftKey && isModKey(ev)) { if (ev.key === 'p' && ev.shiftKey && isModKey(ev)) {

View File

@ -1,42 +0,0 @@
// @flow
import { cloneDeep } from 'lodash';
import { Block, SlateError, Editor } from 'slate';
import { schema as originalSchema } from 'rich-markdown-editor';
const schema = cloneDeep(originalSchema);
// add rules to the schema to ensure the first node is a heading
schema.document.nodes.unshift({ match: { type: 'heading1' }, min: 1, max: 1 });
schema.document.normalize = (editor: Editor, error: SlateError) => {
switch (error.code) {
case 'child_max_invalid': {
return editor.setNodeByKey(
error.child.key,
error.index === 0 ? 'heading1' : 'paragraph'
);
}
case 'child_min_invalid': {
const missingTitle = error.index === 0;
const firstNode = editor.value.document.nodes.get(0);
if (!firstNode) {
editor.insertNodeByKey(error.node.key, 0, Block.create('heading1'));
} else {
editor.setNodeByKey(firstNode.key, { type: 'heading1' });
}
const secondNode = editor.value.document.nodes.get(1);
if (!secondNode) {
editor.insertNodeByKey(error.node.key, 1, Block.create('paragraph'));
} else {
editor.setNodeByKey(secondNode.key, { type: 'paragraph' });
}
if (missingTitle) setImmediate(() => editor.moveFocusToStartOfDocument());
return editor;
}
default:
}
};
export default schema;

View File

@ -1,5 +1,5 @@
// @flow // @flow
import { filter, find, orderBy } from 'lodash'; import { reduce, filter, find, orderBy } from 'lodash';
import BaseStore from './BaseStore'; import BaseStore from './BaseStore';
import RootStore from './RootStore'; import RootStore from './RootStore';
import View from 'models/View'; import View from 'models/View';
@ -19,6 +19,11 @@ export default class ViewsStore extends BaseStore<View> {
); );
} }
countForDocument(documentId: string): number {
const views = this.inDocument(documentId);
return reduce(views, (memo, view) => memo + view.count, 0);
}
touch(documentId: string, userId: string) { touch(documentId: string, userId: string) {
const view = find( const view = find(
this.orderedData, this.orderedData,

View File

@ -425,7 +425,7 @@ router.post('documents.revision', auth(), async ctx => {
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data: presentRevision(revision), data: await presentRevision(revision),
}; };
}); });
@ -445,9 +445,13 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit, limit: ctx.state.pagination.limit,
}); });
const data = await Promise.all(
revisions.map(revision => presentRevision(revision))
);
ctx.body = { ctx.body = {
pagination: ctx.state.pagination, pagination: ctx.state.pagination,
data: revisions.map(presentRevision), data,
}; };
}); });

View File

@ -0,0 +1,19 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('documents', 'version', {
type: Sequelize.SMALLINT,
allowNull: true,
});
await queryInterface.addColumn('revisions', 'version', {
type: Sequelize.SMALLINT,
allowNull: true,
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('documents', 'version');
await queryInterface.removeColumn('revisions', 'version');
}
};

View File

@ -17,7 +17,8 @@ import Revision from './Revision';
const Op = Sequelize.Op; const Op = Sequelize.Op;
const Markdown = new MarkdownSerializer(); const Markdown = new MarkdownSerializer();
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/; const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
const DEFAULT_TITLE = 'Untitled';
export const DOCUMENT_VERSION = 1;
slug.defaults.mode = 'rfc3986'; slug.defaults.mode = 'rfc3986';
const slugify = text => const slugify = text =>
@ -38,6 +39,7 @@ const createRevision = (doc, options = {}) => {
text: doc.text, text: doc.text,
userId: doc.lastModifiedById, userId: doc.lastModifiedById,
editorVersion: doc.editorVersion, editorVersion: doc.editorVersion,
version: doc.version,
documentId: doc.id, documentId: doc.id,
}, },
{ {
@ -50,16 +52,19 @@ const createUrlId = doc => {
return (doc.urlId = doc.urlId || randomstring.generate(10)); return (doc.urlId = doc.urlId || randomstring.generate(10));
}; };
const beforeCreate = async doc => {
doc.version = DOCUMENT_VERSION;
return beforeSave(doc);
};
const beforeSave = async doc => { const beforeSave = async doc => {
const { emoji, title } = parseTitle(doc.text); const { emoji } = parseTitle(doc.text);
// emoji in the title is split out for easier display // emoji in the title is split out for easier display
doc.emoji = emoji; doc.emoji = emoji;
// ensure documents have a title // ensure documents have a title
if (!title) { doc.title = doc.title || '';
doc.title = DEFAULT_TITLE;
}
// add the current user as a collaborator on this doc // add the current user as a collaborator on this doc
if (!doc.collaboratorIds) doc.collaboratorIds = []; if (!doc.collaboratorIds) doc.collaboratorIds = [];
@ -92,6 +97,7 @@ const Document = sequelize.define(
}, },
}, },
}, },
version: DataTypes.SMALLINT,
editorVersion: DataTypes.STRING, editorVersion: DataTypes.STRING,
text: DataTypes.TEXT, text: DataTypes.TEXT,
isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false }, isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false },
@ -105,7 +111,7 @@ const Document = sequelize.define(
paranoid: true, paranoid: true,
hooks: { hooks: {
beforeValidate: createUrlId, beforeValidate: createUrlId,
beforeCreate: beforeSave, beforeCreate: beforeCreate,
beforeUpdate: beforeSave, beforeUpdate: beforeSave,
afterCreate: createRevision, afterCreate: createRevision,
afterUpdate: createRevision, afterUpdate: createRevision,
@ -427,6 +433,26 @@ Document.addHook('afterCreate', async model => {
// Instance methods // Instance methods
Document.prototype.toMarkdown = function() {
const text = unescape(this.text);
if (this.version) {
return `# ${this.title}\n\n${text}`;
}
return text;
};
Document.prototype.migrateVersion = function() {
// migrate from document version 0 -> 1 means removing the title from the
// document text attribute.
if (!this.version) {
this.text = this.text.replace(/^#\s(.*)\n/, '');
this.version = 1;
return this.save({ silent: true, hooks: false });
}
};
// Note: This method marks the document and it's children as deleted // Note: This method marks the document and it's children as deleted
// in the database, it does not permanantly delete them OR remove // in the database, it does not permanantly delete them OR remove
// from the collection structure. // from the collection structure.

View File

@ -7,6 +7,7 @@ const Revision = sequelize.define('revision', {
defaultValue: DataTypes.UUIDV4, defaultValue: DataTypes.UUIDV4,
primaryKey: true, primaryKey: true,
}, },
version: DataTypes.SMALLINT,
editorVersion: DataTypes.STRING, editorVersion: DataTypes.STRING,
title: DataTypes.STRING, title: DataTypes.STRING,
text: DataTypes.TEXT, text: DataTypes.TEXT,
@ -31,4 +32,14 @@ Revision.associate = models => {
); );
}; };
Revision.prototype.migrateVersion = function() {
// migrate from revision version 0 -> 1 means removing the title from the
// revision text attribute.
if (!this.version) {
this.text = this.text.replace(/^#\s(.*)\n/, '');
this.version = 1;
return this.save({ silent: true, hooks: false });
}
};
export default Revision; export default Revision;

View File

@ -31,7 +31,9 @@ export default async function present(document: Document, options: ?Options) {
...options, ...options,
}; };
const text = options.isPublic await document.migrateVersion();
let text = options.isPublic
? await replaceImageAttachments(document.text) ? await replaceImageAttachments(document.text)
: document.text; : document.text;

View File

@ -2,7 +2,9 @@
import { Revision } from '../models'; import { Revision } from '../models';
import presentUser from './user'; import presentUser from './user';
export default function present(revision: Revision) { export default async function present(revision: Revision) {
await revision.migrateVersion();
return { return {
id: revision.id, id: revision.id,
documentId: revision.documentId, documentId: revision.documentId,

View File

@ -3,14 +3,13 @@ import fs from 'fs';
import JSZip from 'jszip'; import JSZip from 'jszip';
import tmp from 'tmp'; import tmp from 'tmp';
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import unescape from '../../shared/utils/unescape';
import { Attachment, Collection, Document } from '../models'; import { Attachment, Collection, Document } from '../models';
import { getImageByKey } from './s3'; import { getImageByKey } from './s3';
async function addToArchive(zip, documents) { async function addToArchive(zip, documents) {
for (const doc of documents) { for (const doc of documents) {
const document = await Document.findByPk(doc.id); const document = await Document.findByPk(doc.id);
let text = unescape(document.text); let text = document.toMarkdown();
const attachments = await Attachment.findAll({ const attachments = await Attachment.findAll({
where: { documentId: document.id }, where: { documentId: document.id },
@ -21,7 +20,7 @@ async function addToArchive(zip, documents) {
text = text.replace(attachment.redirectUrl, encodeURI(attachment.key)); text = text.replace(attachment.redirectUrl, encodeURI(attachment.key));
} }
zip.file(`${document.title}.md`, text); zip.file(`${document.title || 'Untitled'}.md`, text);
if (doc.children && doc.children.length) { if (doc.children && doc.children.length) {
const folder = zip.folder(document.title); const folder = zip.folder(document.title);