diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 5131b17cb..740d26af7 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -152,7 +152,7 @@ class DocumentPreview extends React.Component { {...rest} > - + <Title text={document.title || 'Untitled'} highlight={highlight} /> {!document.isDraft && !document.isArchived && ( <Actions> diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index 370c55c6d..c1f8532e3 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -113,7 +113,7 @@ const StyledEditor = styled(RichMarkdownEditor)` visibility: hidden; } } - p:nth-child(2):last-child { + p:nth-child(1):last-child { ${Placeholder} { 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; + } `; /* diff --git a/app/components/PublishingInfo.js b/app/components/PublishingInfo.js index 29747d231..b7ab86004 100644 --- a/app/components/PublishingInfo.js +++ b/app/components/PublishingInfo.js @@ -1,12 +1,13 @@ // @flow import * as React from 'react'; -import { inject } from 'mobx-react'; +import { inject, observer } from 'mobx-react'; import styled from 'styled-components'; import Document from 'models/Document'; import Flex from 'shared/components/Flex'; import Time from 'shared/components/Time'; import Breadcrumb from 'shared/components/Breadcrumb'; import CollectionsStore from 'stores/CollectionsStore'; +import AuthStore from 'stores/AuthStore'; const Container = styled(Flex)` color: ${props => props.theme.textTertiary}; @@ -23,29 +24,33 @@ const Modified = styled.span` type Props = { collections: CollectionsStore, + auth: AuthStore, showCollection?: boolean, showPublished?: boolean, document: Document, - views?: number, + children: React.Node, }; function PublishingInfo({ + auth, collections, showPublished, showCollection, document, + children, + ...rest }: Props) { const { modifiedSinceViewed, updatedAt, updatedBy, + createdAt, publishedAt, archivedAt, deletedAt, isDraft, } = document; - const neverUpdated = publishedAt === updatedAt; let content; if (deletedAt) { @@ -60,7 +65,13 @@ function PublishingInfo({ archived <Time dateTime={archivedAt} /> ago </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 = ( <span> published <Time dateTime={publishedAt} /> ago @@ -81,10 +92,11 @@ function PublishingInfo({ } const collection = collections.get(document.collectionId); + const updatedByMe = auth.user && auth.user.id === updatedBy.id; return ( - <Container align="center"> - {updatedBy.name}  + <Container align="center" {...rest}> + {updatedByMe ? 'You' : updatedBy.name}  {content} {showCollection && collection && ( @@ -95,8 +107,9 @@ function PublishingInfo({ </strong> </span> )} + {children} </Container> ); } -export default inject('collections')(PublishingInfo); +export default inject('collections', 'auth')(observer(PublishingInfo)); diff --git a/app/models/Document.js b/app/models/Document.js index 93b7d4f6c..0dbeab8d2 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -40,18 +40,9 @@ export default class Document extends BaseModel { shareUrl: ?string; revision: number; - constructor(data?: Object = {}, store: DocumentsStore) { - super(data, store); - this.updateTitle(); - } - - @action - updateTitle() { - const { title, emoji } = parseTitle(this.text); - - if (title) { - set(this, { title, emoji }); - } + get emoji() { + const { emoji } = parseTitle(this.title); + return emoji; } @computed @@ -61,15 +52,7 @@ export default class Document extends BaseModel { @computed get isOnlyTitle(): boolean { - const { title } = parseTitle(this.text); - - // find and extract title - const trimmedBody = this.text - .trim() - .replace(/^#/, '') - .trim(); - - return unescape(trimmedBody) === title; + return !this.text.trim(); } @computed @@ -117,7 +100,6 @@ export default class Document extends BaseModel { @action updateFromJson = data => { set(this, data); - this.updateTitle(); }; archive = () => { @@ -182,7 +164,6 @@ export default class Document extends BaseModel { const isCreating = !this.id; this.isSaving = true; - this.updateTitle(); try { if (isCreating) { @@ -221,14 +202,17 @@ export default class Document extends BaseModel { // Ensure the document is upto date with latest server contents 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 a = document.createElement('a'); // Firefox support requires the anchor tag be in the DOM to trigger the dl if (document.body) document.body.appendChild(a); a.href = url; - a.download = `${this.title}.md`; + a.download = `${this.title || 'Untitled'}.md`; a.click(); }; } diff --git a/app/models/Document.test.js b/app/models/Document.test.js deleted file mode 100644 index a31260ffe..000000000 --- a/app/models/Document.test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/app/scenes/Document/components/Contents.js b/app/scenes/Document/components/Contents.js index bd0720241..27d85b2ed 100644 --- a/app/scenes/Document/components/Contents.js +++ b/app/scenes/Document/components/Contents.js @@ -43,6 +43,15 @@ export default function Contents({ document }: Props) { [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 ( <div> <Wrapper> @@ -52,7 +61,7 @@ export default function Contents({ document }: Props) { {headings.map(heading => ( <ListItem key={heading.slug} - level={heading.level} + level={heading.level - headingAdjustment} active={activeSlug === heading.slug} > <Link href={`#${heading.slug}`}>{heading.title}</Link> diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index 3ba2ce9ab..e316a24b4 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -5,6 +5,7 @@ import styled from 'styled-components'; import breakpoint from 'styled-components-breakpoint'; import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; +import { schema } from 'rich-markdown-editor'; import { Prompt, Route, withRouter } from 'react-router-dom'; import type { Location, RouterHistory } from 'react-router-dom'; import keydown from 'react-keydown'; @@ -37,8 +38,6 @@ import AuthStore from 'stores/AuthStore'; import Document from 'models/Document'; import Revision from 'models/Revision'; -import schema from '../schema'; - let EditorImport; const AUTOSAVE_DELAY = 3000; const IS_DIRTY_DELAY = 500; @@ -75,9 +74,11 @@ class DocumentScene extends React.Component<Props> { @observable isDirty: boolean = false; @observable isEmpty: boolean = true; @observable moveModalOpen: boolean = false; + @observable title: string; constructor(props) { super(); + this.title = props.document.title; this.loadEditor(); } @@ -168,13 +169,20 @@ class DocumentScene extends React.Component<Props> { // get the latest version of the editor text value const text = this.getEditorText ? this.getEditorText() : document.text; + const title = this.title; // 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 - 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; let isNew = !document.id; @@ -201,10 +209,12 @@ class DocumentScene extends React.Component<Props> { updateIsDirty = () => { const { document } = this.props; 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 - this.isEmpty = !editorText || editorText === '#'; - this.isDirty = !!document && editorText !== document.text.trim(); + this.isEmpty = (!editorText || editorText === '#') && !this.title; + this.isDirty = bodyChanged || titleChanged; }; updateIsDirtyDebounced = debounce(this.updateIsDirty, IS_DIRTY_DELAY); @@ -223,6 +233,12 @@ class DocumentScene extends React.Component<Props> { this.autosave(); }; + onChangeTitle = event => { + this.title = event.target.value; + this.updateIsDirtyDebounced(); + this.autosave(); + }; + goBack = () => { let url; if (this.props.document.url) { @@ -245,7 +261,7 @@ class DocumentScene extends React.Component<Props> { } = this.props; const team = auth.team; const Editor = this.editorComponent; - const isShare = match.params.shareId; + const isShare = !!match.params.shareId; if (!Editor) { return <Loading location={location} />; @@ -334,13 +350,16 @@ class DocumentScene extends React.Component<Props> { readOnly && <Contents document={revision || document} />} <Editor id={document.id} + isDraft={document.isDraft} key={disableEmbeds ? 'embeds-disabled' : 'embeds-enabled'} + title={revision ? revision.title : this.title} + document={document} defaultValue={revision ? revision.text : document.text} - pretitle={document.emoji} disableEmbeds={disableEmbeds} onImageUploadStart={this.onImageUploadStart} onImageUploadStop={this.onImageUploadStop} onSearchLink={this.props.onSearchLink} + onChangeTitle={this.onChangeTitle} onChange={this.onChange} onSave={this.onSave} onPublish={this.onPublish} @@ -350,7 +369,6 @@ class DocumentScene extends React.Component<Props> { schema={schema} /> </Flex> - {readOnly && !isShare && !revision && ( @@ -389,7 +407,6 @@ const MaxWidth = styled(Flex)` ${breakpoint('desktopLarge')` max-width: calc(48px + 46em); - box-sizing: `}; `; diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 1a7b9ea78..a5131ba72 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -1,19 +1,32 @@ // @flow import * as React from 'react'; +import styled from 'styled-components'; +import { inject, observer } from 'mobx-react'; import Editor from 'components/Editor'; +import PublishingInfo from 'components/PublishingInfo'; 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'; type Props = {| - defaultValue?: string, + onChangeTitle: (event: SyntheticInputEvent<>) => void, + title: string, + defaultValue: string, + document: Document, + views: ViewsStore, + isDraft: boolean, readOnly?: boolean, |}; +@observer class DocumentEditor extends React.Component<Props> { editor: ?Editor; componentDidMount() { - if (!this.props.defaultValue) { + if (this.props.title) { 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() { - 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 ( - <React.Fragment> + <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> +  · Viewed{' '} + {totalViews === 1 ? 'once' : `${totalViews} times`} + </React.Fragment> + ) : null} + </Meta> <Editor ref={ref => (this.editor = ref)} - autoFocus={!this.props.defaultValue} + autoFocus={title && !this.props.defaultValue} + placeholder="…the rest is up to you" plugins={plugins} grow {...this.props} /> {!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); diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index 0172c9089..daec21b50 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -180,6 +180,7 @@ class Header extends React.Component<Props> { <Status>Saving…</Status> </Action> )} +   <Collaborators document={document} currentUserId={auth.user ? auth.user.id : undefined} @@ -280,7 +281,6 @@ class Header extends React.Component<Props> { /> </Action> )} - {!isEditing && ( <React.Fragment> <Separator /> diff --git a/app/scenes/Document/components/plugins.js b/app/scenes/Document/components/plugins.js index 613d5b02a..688a4dd90 100644 --- a/app/scenes/Document/components/plugins.js +++ b/app/scenes/Document/components/plugins.js @@ -1,31 +1,8 @@ // @flow -import { Node, Editor } from 'slate'; -import Placeholder from 'rich-markdown-editor/lib/plugins/Placeholder'; +import { Editor } from 'slate'; import isModKey from 'rich-markdown-editor/lib/lib/isModKey'; 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) { if (ev.key === 'p' && ev.shiftKey && isModKey(ev)) { diff --git a/app/scenes/Document/schema.js b/app/scenes/Document/schema.js deleted file mode 100644 index 87a2f1bff..000000000 --- a/app/scenes/Document/schema.js +++ /dev/null @@ -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; diff --git a/app/stores/ViewsStore.js b/app/stores/ViewsStore.js index cb7e27d92..7cd03e7d2 100644 --- a/app/stores/ViewsStore.js +++ b/app/stores/ViewsStore.js @@ -1,5 +1,5 @@ // @flow -import { filter, find, orderBy } from 'lodash'; +import { reduce, filter, find, orderBy } from 'lodash'; import BaseStore from './BaseStore'; import RootStore from './RootStore'; 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) { const view = find( this.orderedData, diff --git a/server/api/documents.js b/server/api/documents.js index 1bfeec455..bf73b1c3f 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -425,7 +425,7 @@ router.post('documents.revision', auth(), async ctx => { ctx.body = { 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, }); + const data = await Promise.all( + revisions.map(revision => presentRevision(revision)) + ); + ctx.body = { pagination: ctx.state.pagination, - data: revisions.map(presentRevision), + data, }; }); diff --git a/server/migrations/20200330053639-document-version.js b/server/migrations/20200330053639-document-version.js new file mode 100644 index 000000000..011b31029 --- /dev/null +++ b/server/migrations/20200330053639-document-version.js @@ -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'); + } +}; \ No newline at end of file diff --git a/server/models/Document.js b/server/models/Document.js index 1ed91b282..36e3eefd7 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -17,7 +17,8 @@ import Revision from './Revision'; const Op = Sequelize.Op; const Markdown = new MarkdownSerializer(); 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'; const slugify = text => @@ -38,6 +39,7 @@ const createRevision = (doc, options = {}) => { text: doc.text, userId: doc.lastModifiedById, editorVersion: doc.editorVersion, + version: doc.version, documentId: doc.id, }, { @@ -50,16 +52,19 @@ const createUrlId = doc => { return (doc.urlId = doc.urlId || randomstring.generate(10)); }; +const beforeCreate = async doc => { + doc.version = DOCUMENT_VERSION; + return beforeSave(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 doc.emoji = emoji; // ensure documents have a title - if (!title) { - doc.title = DEFAULT_TITLE; - } + doc.title = doc.title || ''; // add the current user as a collaborator on this doc if (!doc.collaboratorIds) doc.collaboratorIds = []; @@ -92,6 +97,7 @@ const Document = sequelize.define( }, }, }, + version: DataTypes.SMALLINT, editorVersion: DataTypes.STRING, text: DataTypes.TEXT, isWelcome: { type: DataTypes.BOOLEAN, defaultValue: false }, @@ -105,7 +111,7 @@ const Document = sequelize.define( paranoid: true, hooks: { beforeValidate: createUrlId, - beforeCreate: beforeSave, + beforeCreate: beforeCreate, beforeUpdate: beforeSave, afterCreate: createRevision, afterUpdate: createRevision, @@ -427,6 +433,26 @@ Document.addHook('afterCreate', async model => { // 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 // in the database, it does not permanantly delete them OR remove // from the collection structure. diff --git a/server/models/Revision.js b/server/models/Revision.js index 17660d538..f583db56a 100644 --- a/server/models/Revision.js +++ b/server/models/Revision.js @@ -7,6 +7,7 @@ const Revision = sequelize.define('revision', { defaultValue: DataTypes.UUIDV4, primaryKey: true, }, + version: DataTypes.SMALLINT, editorVersion: DataTypes.STRING, title: DataTypes.STRING, 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; diff --git a/server/presenters/document.js b/server/presenters/document.js index 1f95420b7..4650b7085 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -31,7 +31,9 @@ export default async function present(document: Document, options: ?Options) { ...options, }; - const text = options.isPublic + await document.migrateVersion(); + + let text = options.isPublic ? await replaceImageAttachments(document.text) : document.text; diff --git a/server/presenters/revision.js b/server/presenters/revision.js index 40e19e590..04e364b2d 100644 --- a/server/presenters/revision.js +++ b/server/presenters/revision.js @@ -2,7 +2,9 @@ import { Revision } from '../models'; import presentUser from './user'; -export default function present(revision: Revision) { +export default async function present(revision: Revision) { + await revision.migrateVersion(); + return { id: revision.id, documentId: revision.documentId, diff --git a/server/utils/zip.js b/server/utils/zip.js index eadcf332e..fe98595d6 100644 --- a/server/utils/zip.js +++ b/server/utils/zip.js @@ -3,14 +3,13 @@ import fs from 'fs'; import JSZip from 'jszip'; import tmp from 'tmp'; import * as Sentry from '@sentry/node'; -import unescape from '../../shared/utils/unescape'; import { Attachment, Collection, Document } from '../models'; import { getImageByKey } from './s3'; async function addToArchive(zip, documents) { for (const doc of documents) { const document = await Document.findByPk(doc.id); - let text = unescape(document.text); + let text = document.toMarkdown(); const attachments = await Attachment.findAll({ where: { documentId: document.id }, @@ -21,7 +20,7 @@ async function addToArchive(zip, documents) { 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) { const folder = zip.folder(document.title);