import { pagePathUtils } from '@growi/core'; import * as entities from 'entities'; import * as toastr from 'toastr'; import { Container } from 'unstated'; import { EditorMode } from '~/stores/ui'; import loggerFactory from '~/utils/logger'; import { toastError } from '../util/apiNotification'; import { apiPost } from '../util/apiv1-client'; import { apiv3Post } from '../util/apiv3-client'; import { DetachCodeBlockInterceptor, RestoreCodeBlockInterceptor, } from '../util/interceptor/detach-code-blocks'; import { DrawioInterceptor, } from '../util/interceptor/drawio-interceptor'; const { isTrashPage } = pagePathUtils; const logger = loggerFactory('growi:services:PageContainer'); /** * Service container related to Page * @extends {Container} unstated Container */ export default class PageContainer extends Container { constructor(appContainer) { super(); this.appContainer = appContainer; this.appContainer.registerContainer(this); this.state = {}; const mainContent = document.querySelector('#content-main'); if (mainContent == null) { logger.debug('#content-main element is not exists'); return; } const revisionId = mainContent.getAttribute('data-page-revision-id'); const path = decodeURI(mainContent.getAttribute('data-path')); this.state = { // local page data markdown: null, // will be initialized after initStateMarkdown() pageId: mainContent.getAttribute('data-page-id'), revisionId, revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'), path, isEmpty: mainContent.getAttribute('data-page-is-empty'), createdAt: mainContent.getAttribute('data-page-created-at'), // please use useCurrentUpdatedAt instead updatedAt: mainContent.getAttribute('data-page-updated-at'), deletedAt: mainContent.getAttribute('data-page-deleted-at') || null, isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null, isTrashPage: isTrashPage(path), isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')), isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')), isPageExist: mainContent.getAttribute('data-page-id') != null, pageUser: JSON.parse(mainContent.getAttribute('data-page-user')), tags: null, hasChildren: JSON.parse(mainContent.getAttribute('data-page-has-children')), templateTagData: mainContent.getAttribute('data-template-tags') || null, shareLinksNumber: mainContent.getAttribute('data-share-links-number'), shareLinkId: JSON.parse(mainContent.getAttribute('data-share-link-id') || null), // latest(on remote) information remoteRevisionId: revisionId, remoteRevisionBody: null, remoteRevisionUpdateAt: null, revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null, lastUpdateUsername: mainContent.getAttribute('data-page-last-update-username') || null, deleteUsername: mainContent.getAttribute('data-page-delete-username') || null, pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null, hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'), isHackmdDraftUpdatingInRealtime: false, isConflictDiffModalOpen: false, }; // parse creator, lastUpdateUser and revisionAuthor try { this.state.creator = JSON.parse(mainContent.getAttribute('data-page-creator')); } catch (e) { logger.warn('The data of \'data-page-creator\' is invalid', e); } try { this.state.revisionAuthor = JSON.parse(mainContent.getAttribute('data-page-revision-author')); this.state.lastUpdateUser = JSON.parse(mainContent.getAttribute('data-page-revision-author')); } catch (e) { logger.warn('The data of \'data-page-revision-author\' is invalid', e); } const { interceptorManager } = window; interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(), 10); // process as soon as possible interceptorManager.addInterceptor(new DrawioInterceptor(), 20); interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(), 900); // process as late as possible this.initStateMarkdown(); this.save = this.save.bind(this); this.emitJoinPageRoomRequest = this.emitJoinPageRoomRequest.bind(this); this.emitJoinPageRoomRequest(); this.addWebSocketEventHandlers = this.addWebSocketEventHandlers.bind(this); this.addWebSocketEventHandlers(); const unlinkPageButton = document.getElementById('unlink-page-button'); if (unlinkPageButton != null) { unlinkPageButton.addEventListener('click', async() => { try { const res = await apiPost('/pages.unlink', { path }); window.location.href = encodeURI(`${res.path}?unlinked=true`); } catch (err) { toastError(err); } }); } } /** * Workaround for the mangling in production build to break constructor.name */ static getClassName() { return 'PageContainer'; } /** * initialize state for markdown data */ initStateMarkdown() { let pageContent = ''; const rawText = document.getElementById('raw-text-original'); if (rawText) { pageContent = rawText.innerHTML; } const markdown = entities.decodeHTML(pageContent); this.state.markdown = markdown; } setLatestRemotePageData(s2cMessagePageUpdated) { const newState = { remoteRevisionId: s2cMessagePageUpdated.revisionId, remoteRevisionBody: s2cMessagePageUpdated.revisionBody, remoteRevisionUpdateAt: s2cMessagePageUpdated.revisionUpdateAt, revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced, // TODO // TODO remove lastUpdateUsername and refactor parts that lastUpdateUsername is used lastUpdateUsername: s2cMessagePageUpdated.lastUpdateUsername, lastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser, }; if (s2cMessagePageUpdated.hasDraftOnHackmd != null) { newState.hasDraftOnHackmd = s2cMessagePageUpdated.hasDraftOnHackmd; } this.setState(newState); } /** * save success handler * @param {object} page Page instance * @param {Array[Tag]} tags Array of Tag * @param {object} revision Revision instance */ updateStateAfterSave(page, tags, revision, editorMode) { // update state of PageContainer const newState = { pageId: page._id, revisionId: revision._id, revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000, remoteRevisionId: revision._id, revisionAuthor: revision.author, revisionIdHackmdSynced: page.revisionHackmdSynced, hasDraftOnHackmd: page.hasDraftOnHackmd, markdown: revision.body, createdAt: page.createdAt, updatedAt: page.updatedAt, }; if (tags != null) { newState.tags = tags; } this.setState(newState); // Update PageEditor component if (editorMode !== EditorMode.Editor) { // eslint-disable-next-line no-undef globalEmitter.emit('updateEditorValue', newState.markdown); } // PageEditorByHackmd component const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd'); if (pageEditorByHackmd != null) { // reset if (editorMode !== EditorMode.HackMD) { pageEditorByHackmd.reset(); } } } /** * update page meta data * @param {object} page Page instance * @param {object} revision Revision instance * @param {String[]} tags Array of Tag */ updatePageMetaData(page, revision, tags) { const newState = { revisionId: revision._id, revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000, remoteRevisionId: revision._id, revisionAuthor: revision.author, revisionIdHackmdSynced: page.revisionHackmdSynced, hasDraftOnHackmd: page.hasDraftOnHackmd, updatedAt: page.updatedAt, }; if (tags != null) { newState.tags = tags; } this.setState(newState); } /** * Save page * @param {string} markdown * @param {object} optionsToSave * @return {object} { page: Page, tags: Tag[] } */ async save(markdown, editorMode, optionsToSave = {}) { const { pageId, path } = this.state; let { revisionId } = this.state; const options = Object.assign({}, optionsToSave); if (editorMode === EditorMode.HackMD) { // set option to sync options.isSyncRevisionToHackmd = true; revisionId = this.state.revisionIdHackmdSynced; } let res; if (pageId == null) { res = await this.createPage(path, markdown, options); } else { res = await this.updatePage(pageId, revisionId, markdown, options); } this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode); return res; } async saveAndReload(optionsToSave, editorMode) { if (optionsToSave == null) { const msg = '\'saveAndReload\' requires the \'optionsToSave\' param'; throw new Error(msg); } if (editorMode == null) { logger.warn('\'saveAndReload\' requires the \'editorMode\' param'); return; } const { pageId, path } = this.state; let { revisionId } = this.state; const options = Object.assign({}, optionsToSave); let markdown; if (editorMode === EditorMode.HackMD) { const pageEditorByHackmd = this.appContainer.getComponentInstance('PageEditorByHackmd'); markdown = await pageEditorByHackmd.getMarkdown(); // set option to sync options.isSyncRevisionToHackmd = true; revisionId = this.state.revisionIdHackmdSynced; } else { const pageEditor = this.appContainer.getComponentInstance('PageEditor'); markdown = pageEditor.getMarkdown(); } let res; if (pageId == null) { res = await this.createPage(path, markdown, options); } else { res = await this.updatePage(pageId, revisionId, markdown, options); } const editorContainer = this.appContainer.getContainer('EditorContainer'); editorContainer.clearDraft(path); window.location.href = path; return res; } async createPage(pagePath, markdown, tmpParams) { const socketIoContainer = this.appContainer.getContainer('SocketIoContainer'); // clone const params = Object.assign(tmpParams, { path: pagePath, body: markdown, }); const res = await apiv3Post('/pages/', params); const { page, tags, revision } = res.data; return { page, tags, revision }; } async updatePage(pageId, revisionId, markdown, tmpParams) { const socketIoContainer = this.appContainer.getContainer('SocketIoContainer'); // clone const params = Object.assign(tmpParams, { page_id: pageId, revision_id: revisionId, body: markdown, }); const res = await apiPost('/pages.update', params); if (!res.ok) { throw new Error(res.error); } return res; } showSuccessToastr() { toastr.success(undefined, 'Saved successfully', { closeButton: true, progressBar: true, newestOnTop: false, showDuration: '100', hideDuration: '100', timeOut: '1200', extendedTimeOut: '150', }); } showErrorToastr(error) { toastr.error(error.message, 'Error occured', { closeButton: true, progressBar: true, newestOnTop: false, showDuration: '100', hideDuration: '100', timeOut: '3000', }); } // request to server so the client to join a room for each page emitJoinPageRoomRequest() { const socketIoContainer = this.appContainer.getContainer('SocketIoContainer'); const socket = socketIoContainer.getSocket(); socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId }); } addWebSocketEventHandlers() { // eslint-disable-next-line @typescript-eslint/no-this-alias const pageContainer = this; const socketIoContainer = this.appContainer.getContainer('SocketIoContainer'); const socket = socketIoContainer.getSocket(); socket.on('page:create', (data) => { logger.debug({ obj: data }, `websocket on 'page:create'`); // eslint-disable-line quotes // update remote page data const { s2cMessagePageUpdated } = data; if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) { pageContainer.setLatestRemotePageData(s2cMessagePageUpdated); } }); socket.on('page:update', (data) => { logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes // update remote page data const { s2cMessagePageUpdated } = data; if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) { pageContainer.setLatestRemotePageData(s2cMessagePageUpdated); } }); socket.on('page:delete', (data) => { logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes // update remote page data const { s2cMessagePageUpdated } = data; if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) { pageContainer.setLatestRemotePageData(s2cMessagePageUpdated); } }); socket.on('page:editingWithHackmd', (data) => { logger.debug({ obj: data }, `websocket on 'page:editingWithHackmd'`); // eslint-disable-line quotes // update isHackmdDraftUpdatingInRealtime const { s2cMessagePageUpdated } = data; if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) { pageContainer.setState({ isHackmdDraftUpdatingInRealtime: true }); } }); } /* TODO GW-325 */ retrieveMyBookmarkList() { } async resolveConflict(markdown, editorMode) { const { pageId, remoteRevisionId, path } = this.state; const editorContainer = this.appContainer.getContainer('EditorContainer'); const options = editorContainer.getCurrentOptionsToSave(); const optionsToSave = Object.assign({}, options); const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave); editorContainer.clearDraft(path); this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode); // Update PageEditor component if (editorMode !== EditorMode.Editor) { // eslint-disable-next-line no-undef globalEmitter.emit('updateEditorValue', markdown); } editorContainer.setState({ tags: res.tags }); return res; } }