├── .eslintrc
├── .gitattributes
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── commitlint.config.js
├── lib
├── adminsockets.js
├── controllers.js
├── migrator.js
└── websockets.js
├── library.js
├── package.json
├── plugin.json
├── renovate.json
├── screenshots
├── desktop.png
└── mobile.png
├── static
├── lib
│ ├── .eslintrc
│ ├── admin.js
│ ├── client.js
│ ├── emoji.js
│ └── quill-nbb.js
├── scss
│ ├── overrides.scss
│ ├── post.scss
│ └── quill.scss
└── templates
│ ├── admin
│ └── plugins
│ │ └── composer-quill.tpl
│ ├── composer.tpl
│ ├── modals
│ └── topic-scheduler.tpl
│ ├── partials
│ ├── composer-formatting.tpl
│ ├── composer-tags.tpl
│ ├── composer-title-container.tpl
│ └── composer-write-preview.tpl
│ └── plugins
│ └── composer-quill
│ └── modals
│ ├── migrate-in.tpl
│ └── migrate-out.tpl
└── yarn.lock
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "nodebb"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
4 | # Custom for Visual Studio
5 | *.cs diff=csharp
6 | *.sln merge=union
7 | *.csproj merge=union
8 | *.vbproj merge=union
9 | *.fsproj merge=union
10 | *.dbproj merge=union
11 |
12 | # Standard to msysgit
13 | *.doc diff=astextplain
14 | *.DOC diff=astextplain
15 | *.docx diff=astextplain
16 | *.DOCX diff=astextplain
17 | *.dot diff=astextplain
18 | *.DOT diff=astextplain
19 | *.pdf diff=astextplain
20 | *.PDF diff=astextplain
21 | *.rtf diff=astextplain
22 | *.RTF diff=astextplain
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | #################
2 | ## Eclipse
3 | #################
4 |
5 | *.pydevproject
6 | .project
7 | .metadata
8 | bin/
9 | tmp/
10 | *.tmp
11 | *.bak
12 | *.swp
13 | *~.nib
14 | local.properties
15 | .classpath
16 | .settings/
17 | .loadpath
18 |
19 | # External tool builders
20 | .externalToolBuilders/
21 |
22 | # Locally stored "Eclipse launch configurations"
23 | *.launch
24 |
25 | # CDT-specific
26 | .cproject
27 |
28 | # PDT-specific
29 | .buildpath
30 |
31 |
32 | #################
33 | ## Visual Studio
34 | #################
35 |
36 | ## Ignore Visual Studio temporary files, build results, and
37 | ## files generated by popular Visual Studio add-ons.
38 |
39 | # User-specific files
40 | *.suo
41 | *.user
42 | *.sln.docstates
43 |
44 | # Build results
45 |
46 | [Dd]ebug/
47 | [Rr]elease/
48 | x64/
49 | build/
50 | [Bb]in/
51 | [Oo]bj/
52 |
53 | # MSTest test Results
54 | [Tt]est[Rr]esult*/
55 | [Bb]uild[Ll]og.*
56 |
57 | *_i.c
58 | *_p.c
59 | *.ilk
60 | *.meta
61 | *.obj
62 | *.pch
63 | *.pdb
64 | *.pgc
65 | *.pgd
66 | *.rsp
67 | *.sbr
68 | *.tlb
69 | *.tli
70 | *.tlh
71 | *.tmp
72 | *.tmp_proj
73 | *.log
74 | *.vspscc
75 | *.vssscc
76 | .builds
77 | *.pidb
78 | *.log
79 | *.scc
80 |
81 | # Visual C++ cache files
82 | ipch/
83 | *.aps
84 | *.ncb
85 | *.opensdf
86 | *.sdf
87 | *.cachefile
88 |
89 | # Visual Studio profiler
90 | *.psess
91 | *.vsp
92 | *.vspx
93 |
94 | # Guidance Automation Toolkit
95 | *.gpState
96 |
97 | # ReSharper is a .NET coding add-in
98 | _ReSharper*/
99 | *.[Rr]e[Ss]harper
100 |
101 | # TeamCity is a build add-in
102 | _TeamCity*
103 |
104 | # DotCover is a Code Coverage Tool
105 | *.dotCover
106 |
107 | # NCrunch
108 | *.ncrunch*
109 | .*crunch*.local.xml
110 |
111 | # Installshield output folder
112 | [Ee]xpress/
113 |
114 | # DocProject is a documentation generator add-in
115 | DocProject/buildhelp/
116 | DocProject/Help/*.HxT
117 | DocProject/Help/*.HxC
118 | DocProject/Help/*.hhc
119 | DocProject/Help/*.hhk
120 | DocProject/Help/*.hhp
121 | DocProject/Help/Html2
122 | DocProject/Help/html
123 |
124 | # Click-Once directory
125 | publish/
126 |
127 | # Publish Web Output
128 | *.Publish.xml
129 | *.pubxml
130 |
131 | # NuGet Packages Directory
132 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line
133 | #packages/
134 |
135 | # Windows Azure Build Output
136 | csx
137 | *.build.csdef
138 |
139 | # Windows Store app package directory
140 | AppPackages/
141 |
142 | # Others
143 | sql/
144 | *.Cache
145 | ClientBin/
146 | [Ss]tyle[Cc]op.*
147 | ~$*
148 | *~
149 | *.dbmdl
150 | *.[Pp]ublish.xml
151 | *.pfx
152 | *.publishsettings
153 |
154 | # RIA/Silverlight projects
155 | Generated_Code/
156 |
157 | # Backup & report files from converting an old project file to a newer
158 | # Visual Studio version. Backup files are not needed, because we have git ;-)
159 | _UpgradeReport_Files/
160 | Backup*/
161 | UpgradeLog*.XML
162 | UpgradeLog*.htm
163 |
164 | # SQL Server files
165 | App_Data/*.mdf
166 | App_Data/*.ldf
167 |
168 | #############
169 | ## Windows detritus
170 | #############
171 |
172 | # Windows image file caches
173 | Thumbs.db
174 | ehthumbs.db
175 |
176 | # Folder config file
177 | Desktop.ini
178 |
179 | # Recycle Bin used on file shares
180 | $RECYCLE.BIN/
181 |
182 | # Mac crap
183 | .DS_Store
184 |
185 |
186 | #############
187 | ## Python
188 | #############
189 |
190 | *.py[co]
191 |
192 | # Packages
193 | *.egg
194 | *.egg-info
195 | dist/
196 | build/
197 | eggs/
198 | parts/
199 | var/
200 | sdist/
201 | develop-eggs/
202 | .installed.cfg
203 |
204 | # Installer logs
205 | pip-log.txt
206 |
207 | # Unit test / coverage reports
208 | .coverage
209 | .tox
210 |
211 | #Translations
212 | *.mo
213 |
214 | #Mr Developer
215 | .mr.developer.cfg
216 |
217 | sftp-config.json
218 | node_modules/
219 | package-lock.json
220 |
221 | .idea/
222 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | sftp-config.json
2 | node_modules/
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2018 NodeBB Inc.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Quill composer for NodeBB
2 |
3 | This plugin activates the WYSIWYG Quill composer for NodeBB. Please ensure that:
4 |
5 | * The markdown plugin is disabled (see note below, re: markdown)
6 | * Any other composers (i.e. nodebb-plugin-composer-default) are disabled
7 | * **Warning** This composer saves its data in a unique format that is only compatible with Quill. If you switch to Quill, any posts made with Quill cannot be migrated back to Markdown.
8 |
9 | ## For developers
10 |
11 | You may encounter a LESS build error when this module is not installed via npm:
12 |
13 | ```
14 | error: [build] Encountered error during build step
15 | Error: FileError: './quill/dist/quill.bubble.css' wasn't found. Tried - /some,/directories,/here
16 | /quill.bubble.css,quill/dist/quill.bubble.css in /path/to/nodebb/node_modules/nodebb-plugin-composer-quill/static/less/quill.less on line 2, column 1:
17 | ```
18 |
19 | This is due to npm/yarn's flattening of dependencies. Quill expects these css files to be at root level, so to get around this:
20 |
21 | `cd /path/to/nodebb/node_modules && ln -s nodebb-plugin-composer-quill/node_modules/quill .`
22 |
23 | ## Migration concerns
24 |
25 | ### nodebb-plugin-composer-default/nodebb-plugin-markdown
26 |
27 | If you used the default composer, chances are you also had the markdown plugin active. If that is the case, any posts made before the switch are still in markdown format, and are not saved into html (in a manner than quill can digest). A migrator tool has been bundled with v1.1 of the Quill Composer, which you can use to migrate posts in markdown and html into the Quill-compatible format.
28 |
29 | ### nodebb-plugin-redactor
30 |
31 | Your posts should automatically work with Quill. Redactor saves HTML into the database, and the Quill plugin is set up so it can digest that html and backport it to Quill's internal format if the post is edited. That said, when you edit a post originally made in Redactor, you'll see the html tags, which are now extraneous. You can remove them as part of your edit. Alternatively, you can use the bundled migrator to convert posts to the Quill-compatible format.
32 |
33 | ## Contributors Welcome
34 | This plugin is considered _production ready_. Please report any bugs to our issue tracker and we will take a look.
35 |
36 | If you'd like to look at the [documentation](https://quilljs.com/docs/) and add a feature, or take a look at the GitHub Issues and do something from there then please do. All pull requests lovingly reviewed.
37 |
38 | ## Screenshots
39 |
40 | ### Desktop
41 |
42 | 
43 |
44 | ### Mobile
45 |
46 | 
47 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | extends: ['@commitlint/config-angular'],
5 | rules: {
6 | 'header-max-length': [1, 'always', 72],
7 | 'type-enum': [
8 | 2,
9 | 'always',
10 | [
11 | 'breaking',
12 | 'build',
13 | 'chore',
14 | 'ci',
15 | 'docs',
16 | 'feat',
17 | 'fix',
18 | 'perf',
19 | 'refactor',
20 | 'revert',
21 | 'style',
22 | 'test',
23 | ],
24 | ],
25 | },
26 | };
27 |
--------------------------------------------------------------------------------
/lib/adminsockets.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const async = require('async');
4 | const util = require('util');
5 |
6 | const batch = require.main.require('./src/batch');
7 | const db = require.main.require('./src/database');
8 |
9 | const migrator = require('./migrator');
10 | var Sockets = module.exports;
11 |
12 | Sockets.migrateIn = async function (socket) {
13 | const process = util.promisify(batch.processSortedSet);
14 | const numPosts = await db.sortedSetCard('posts:pid');
15 | let current = 0;
16 | await process('posts:pid', function (ids, next) {
17 | async.eachSeries(ids, async function (id) {
18 | let postData = await db.getObjectFields('post:' + id, ['content', 'quillDelta']);
19 | if (!postData || postData.quillDelta !== null) {
20 | socket.emit('event:composer-quill.migrateUpdate', {
21 | current: current += 1,
22 | total: numPosts,
23 | });
24 |
25 | return;
26 | }
27 |
28 | delete postData.quillDelta;
29 | postData = await migrator.toQuill(postData);
30 | await db.setObject('post:' + id, postData);
31 | socket.emit('event:composer-quill.migrateUpdate', {
32 | current: current += 1,
33 | total: numPosts,
34 | });
35 | }, next);
36 | }, {});
37 |
38 | return true;
39 | };
40 |
41 | Sockets.migrateOut = async function (socket) {
42 | const process = util.promisify(batch.processSortedSet);
43 | const numPosts = await db.sortedSetCard('posts:pid');
44 | let current = 0;
45 | await process('posts:pid', function (ids, next) {
46 | async.eachSeries(ids, async function (id) {
47 | let postData = await db.getObjectFields('post:' + id, ['quillBackup']);
48 | if (!postData || postData.quillBackup === null) {
49 | socket.emit('event:composer-quill.migrateUpdate', {
50 | current: current += 1,
51 | total: numPosts,
52 | });
53 | return;
54 | }
55 |
56 | postData = {
57 | content: postData.quillBackup,
58 | };
59 | await db.setObject('post:' + id, postData);
60 | await db.deleteObjectFields('post:' + id, ['quillDelta', 'quillBackup']);
61 | socket.emit('event:composer-quill.migrateUpdate', {
62 | current: current += 1,
63 | total: numPosts,
64 | });
65 | }, next);
66 | }, {});
67 |
68 | return true;
69 | };
70 |
--------------------------------------------------------------------------------
/lib/controllers.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var Controllers = module.exports;
4 |
5 | Controllers.renderAdminPage = function (req, res, next) {
6 | var quill = module.parent.exports;
7 |
8 | quill.checkCompatibility(function (err, checks) {
9 | if (err) {
10 | return next(err);
11 | }
12 |
13 | res.render('admin/plugins/composer-quill', {
14 | title: 'Quill Composer',
15 | checks: checks,
16 | });
17 | });
18 | };
19 |
--------------------------------------------------------------------------------
/lib/migrator.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const posts = require.main.require('./src/posts');
4 |
5 | const MarkdownIt = require('markdown-it');
6 |
7 | const markdown = new MarkdownIt();
8 | const { QuillDeltaToHtmlConverter } = require('quill-delta-to-html');
9 | const isHtml = require('is-html');
10 |
11 | const winston = require.main.require('winston');
12 |
13 | const Migrator = module.exports;
14 |
15 | Migrator.detect = (postObj) => {
16 | const isHtml = Migrator.isHtml(postObj);
17 |
18 | return Object.freeze({
19 | quill: Migrator.isQuill(postObj),
20 | html: isHtml,
21 | markdown: !isHtml,
22 | });
23 | };
24 |
25 | Migrator.isQuill = postObj => postObj.hasOwnProperty('quillDelta');
26 |
27 | Migrator.isDelta = (content) => {
28 | try {
29 | content = JSON.parse(content);
30 | return content.hasOwnProperty('ops') && Array.isArray(content.ops);
31 | } catch (e) {
32 | return false;
33 | }
34 | };
35 |
36 | Migrator.isHtml = postObj => isHtml(postObj.content);
37 |
38 | Migrator.isMarkdown = postObj => !Migrator.isHTML(postObj);
39 |
40 | Migrator.toHtml = (content) => {
41 | try {
42 | content = JSON.parse(content);
43 | const converter = new QuillDeltaToHtmlConverter(content.ops, {});
44 |
45 | // Quill plugin should fire a hook here, passing converter.renderCustomWith
46 | // Emoji plugin should take that method and register a listener.
47 | // Also toHtml is probably going to end up being asynchronous, then... awaited?
48 | converter.renderCustomWith((customOp) => {
49 | if (customOp.insert.type === 'emoji') {
50 | return ``;
51 | }
52 | });
53 |
54 | return posts.sanitize(converter.convert());
55 | } catch (e) {
56 | // Do nothing
57 | winston.verbose('[plugin/composer-quill (toHtml)] Input not in expected format, skipping.');
58 | return false;
59 | }
60 | };
61 |
62 | Migrator.toQuill = (postObj) => {
63 | const currently = Migrator.detect(postObj);
64 |
65 | if (currently.quill) {
66 | // Delta already available, no action needed
67 | return postObj;
68 | }
69 |
70 | // Preserve existing content for backup purposes
71 | postObj.quillBackup = postObj.content;
72 |
73 | if (currently.markdown) {
74 | // Convert to HTML
75 | postObj.content = markdown.render(postObj.content);
76 | }
77 |
78 | // Finally, convert to delta
79 | postObj.quillDelta = JSON.stringify(require('node-quill-converter').convertHtmlToDelta(postObj.content));
80 |
81 | return postObj;
82 | };
83 |
--------------------------------------------------------------------------------
/lib/websockets.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const Sockets = module.exports;
4 |
5 | Sockets.getEmojiTable = function (socket, data, callback) {
6 | try {
7 | const table = require.main.require('nodebb-plugin-emoji/build/emoji/table.json');
8 | callback(null, table);
9 | } catch (err) {
10 | callback(null, null);
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/library.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const SocketPlugins = require.main.require('./src/socket.io/plugins');
4 | const SocketAdmin = require.main.require('./src/socket.io/admin').plugins;
5 | SocketAdmin['composer-quill'] = require('./lib/adminsockets');
6 |
7 | const defaultComposer = require.main.require('nodebb-plugin-composer-default');
8 | const plugins = module.parent.exports;
9 | const meta = require.main.require('./src/meta');
10 | const posts = require.main.require('./src/posts');
11 | const messaging = require.main.require('./src/messaging');
12 | const helpers = require.main.require('./src/controllers/helpers');
13 |
14 | const async = require('async');
15 |
16 | const winston = require.main.require('winston');
17 | const nconf = require.main.require('nconf');
18 |
19 | const controllers = require('./lib/controllers');
20 | const migrator = require('./lib/migrator');
21 |
22 | const plugin = {};
23 |
24 | plugin.init = function (data, callback) {
25 | const { router } = data;
26 | const hostMiddleware = data.middleware;
27 |
28 | router.get('/admin/plugins/composer-quill', hostMiddleware.admin.buildHeader, controllers.renderAdminPage);
29 | router.get('/api/admin/plugins/composer-quill', controllers.renderAdminPage);
30 |
31 | // Expose the default composer's socket method calls for this composer as well
32 | plugin.checkCompatibility((err, checks) => {
33 | if (err) {
34 | return winston.error(`[plugin/composer-quill] Error initialising plugin: ${err.message}`);
35 | }
36 |
37 | if (checks.composer) {
38 | SocketPlugins.composer = defaultComposer.socketMethods;
39 | SocketPlugins['composer-quill'] = require('./lib/websockets');
40 | } else {
41 | winston.warn('[plugin/composer-quill] Another composer plugin is active! Please disable all other composers.');
42 | }
43 | });
44 |
45 | callback();
46 | };
47 |
48 | plugin.checkCompatibility = function (callback) {
49 | async.parallel({
50 | active: async.apply(plugins.getActive),
51 | markdown: async.apply(meta.settings.get, 'markdown'),
52 | }, (err, data) => {
53 | callback(err, {
54 | markdown: data.active.indexOf('nodebb-plugin-markdown') === -1, // plugin disabled
55 | composer: data.active.filter(plugin => plugin.startsWith('nodebb-plugin-composer-') && plugin !== 'nodebb-plugin-composer-quill').length === 0,
56 | });
57 | });
58 | };
59 |
60 | plugin.addAdminNavigation = function (header, callback) {
61 | header.plugins.push({
62 | route: '/plugins/composer-quill',
63 | icon: 'fa-edit',
64 | name: 'Quill (Composer)',
65 | });
66 |
67 | callback(null, header);
68 | };
69 |
70 | plugin.build = function (data, callback) {
71 | // No plans for a standalone composer route, so handle redirection on f5
72 | const { req } = data;
73 | const { res } = data;
74 |
75 | if (req.query.p) {
76 | if (!res.locals.isAPI) {
77 | if (req.query.p.startsWith(nconf.get('relative_path'))) {
78 | req.query.p = req.query.p.replace(nconf.get('relative_path'), '');
79 | }
80 |
81 | return helpers.redirect(res, req.query.p);
82 | }
83 | return res.render('', {});
84 | } else if (!req.query.pid && !req.query.tid && !req.query.cid) {
85 | return helpers.redirect(res, '/');
86 | }
87 |
88 | callback(null, data);
89 | };
90 |
91 | plugin.savePost = async (data, path = 'post') => {
92 | if (typeof path === 'function') {
93 | path = 'post';
94 | }
95 |
96 | if (migrator.isDelta(data[path].content)) {
97 | // Optimistic case: regular post via quill composer
98 | data[path].quillDelta = data[path].content;
99 | data[path].content = migrator.toHtml(data[path].content);
100 | } else {
101 | // Fallback to handle write-api and such
102 | data[path] = migrator.toQuill(data[path]);
103 | }
104 |
105 | return data;
106 | };
107 |
108 | plugin.savePostQueue = async (data) => {
109 | data = await plugin.savePost(data, 'data');
110 | return data;
111 | };
112 |
113 | plugin.saveChat = (data, callback) => {
114 | if (data.system) {
115 | return callback(null, data);
116 | }
117 |
118 | data.quillDelta = data.content;
119 | data.content = migrator.toHtml(data.content);
120 | callback(null, data);
121 | };
122 |
123 | plugin.append = async (data) => {
124 | const delta = await posts.getPostField(data.pid, 'quillDelta');
125 | if (delta) {
126 | data.body = delta;
127 | }
128 | return data;
129 | };
130 |
131 | plugin.handleRawPost = async (data) => {
132 | const delta = await posts.getPostField(data.postData.pid, 'quillDelta');
133 | if (delta) {
134 | data.postData.content = delta;
135 | }
136 | return data;
137 | };
138 |
139 | plugin.handleMessageEdit = async (data) => {
140 | // Only handle situations where message content is requested
141 | if (data.fields.length > 1 || data.fields[0] !== 'content') {
142 | return data;
143 | }
144 |
145 | const delta = await messaging.getMessageField(data.mid, 'quillDelta');
146 | data.message.content = delta;
147 | return data;
148 | };
149 |
150 | plugin.handleMessageCheck = async ({ content, length }) => {
151 | try {
152 | const delta = JSON.parse(content);
153 | if (!delta.ops) {
154 | throw new Error();
155 | }
156 |
157 | content = delta.ops.reduce((memo, cur) => {
158 | memo += cur.insert ? cur.insert : '';
159 | return memo;
160 | }, '');
161 | length = String(content).trim().length;
162 | } catch (e) {
163 | winston.warn('[plugins/quill/handleMessageCheck] Did not receive a delta, ignoring...');
164 | }
165 |
166 | return { content, length };
167 | };
168 |
169 | module.exports = plugin;
170 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nodebb-plugin-composer-quill",
3 | "version": "4.0.1",
4 | "description": "Quill Composer for NodeBB",
5 | "main": "library.js",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/NodeBB/nodebb-plugin-composer-quill"
9 | },
10 | "keywords": [
11 | "nodebb",
12 | "plugin",
13 | "composer",
14 | "quill"
15 | ],
16 | "author": {
17 | "name": "NodeBB Team",
18 | "email": "sales@nodebb.org"
19 | },
20 | "license": "MIT",
21 | "bugs": {
22 | "url": "https://github.com/NodeBB/nodebb-plugin-composer-quill/issues"
23 | },
24 | "readmeFilename": "README.md",
25 | "nbbpm": {
26 | "compatibility": "^3.2.0"
27 | },
28 | "dependencies": {
29 | "async": "^3.2.0",
30 | "is-html": "^2.0.0",
31 | "markdown-it": "^12.0.0",
32 | "node-quill-converter": "^0.3.2",
33 | "quill": "^1.3.6",
34 | "quill-delta-to-html": "^0.12.0",
35 | "quill-magic-url": "^4.0.0",
36 | "screenfull": "^5.0.0"
37 | },
38 | "husky": {
39 | "hooks": {
40 | "pre-commit": "npx lint-staged",
41 | "commit-msg": "npx commitlint -E HUSKY_GIT_PARAMS"
42 | }
43 | },
44 | "lint-staged": {
45 | "*.js": [
46 | "eslint --fix",
47 | "git add"
48 | ]
49 | },
50 | "devDependencies": {
51 | "@commitlint/cli": "19.8.1",
52 | "@commitlint/config-angular": "19.8.1",
53 | "eslint": "9.28.0",
54 | "eslint-config-airbnb-base": "15.0.0",
55 | "eslint-plugin-import": "2.31.0",
56 | "husky": "9.1.7",
57 | "lint-staged": "16.1.0"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "nodebb-plugin-composer-quill",
3 | "url": "https://github.com/NodeBB/nodebb-plugin-composer-quill",
4 | "hooks": [
5 | { "hook": "static:app.load", "method": "init" },
6 | { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
7 | { "hook": "filter:composer.build", "method": "build" },
8 | { "hook": "filter:post.create", "method": "savePost" },
9 | { "hook": "filter:post.edit", "method": "savePost" },
10 | { "hook": "filter:post-queue.save", "method": "savePostQueue" },
11 | { "hook": "filter:composer.push", "method": "append" },
12 | { "hook": "filter:messaging.save", "method": "saveChat" },
13 | { "hook": "filter:messaging.edit", "method": "saveChat" },
14 | { "hook": "filter:post.getRawPost", "method": "handleRawPost" },
15 | { "hook": "filter:messaging.getFields", "method": "handleMessageEdit" },
16 | { "hook": "filter:messaging.checkContent", "method": "handleMessageCheck" }
17 | ],
18 | "scss": [
19 | "../nodebb-plugin-composer-default/static/scss/composer.scss",
20 | "./static/scss/quill.scss",
21 | "./static/scss/post.scss",
22 | "./static/scss/overrides.scss"
23 | ],
24 | "modules": {
25 | "quill.js": "./node_modules/quill/dist/quill.js",
26 | "quill-magic-url.js": "./node_modules/quill-magic-url/dist/index.js",
27 | "quill-emoji.js": "./static/lib/emoji.js",
28 | "composer.js": "../nodebb-plugin-composer-default/static/lib/composer.js",
29 | "composer/categoryList.js": "../nodebb-plugin-composer-default/static/lib/composer/categoryList.js",
30 | "composer/controls.js": "../nodebb-plugin-composer-default/static/lib/composer/controls.js",
31 | "composer/drafts.js": "../nodebb-plugin-composer-default/static/lib/composer/drafts.js",
32 | "composer/formatting.js": "../nodebb-plugin-composer-default/static/lib/composer/formatting.js",
33 | "composer/preview.js": "../nodebb-plugin-composer-default/static/lib/composer/preview.js",
34 | "composer/resize.js": "../nodebb-plugin-composer-default/static/lib/composer/resize.js",
35 | "composer/scheduler.js": "../nodebb-plugin-composer-default/static/lib/composer/scheduler.js",
36 | "composer/tags.js": "../nodebb-plugin-composer-default/static/lib/composer/tags.js",
37 | "composer/uploads.js": "../nodebb-plugin-composer-default/static/lib/composer/uploads.js",
38 | "composer/autocomplete.js": "../nodebb-plugin-composer-default/static/lib/composer/autocomplete.js",
39 | "composer/post-queue.js": "../nodebb-plugin-composer-default/static/lib/composer/post-queue.js",
40 | "../admin/plugins/composer-quill.js": "./static/lib/admin.js"
41 | },
42 | "scripts": [
43 | "./static/lib/quill-nbb.js",
44 | "./static/lib/client.js",
45 | "./node_modules/screenfull/dist/screenfull.js"
46 | ],
47 | "templates": "static/templates"
48 | }
49 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/screenshots/desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NodeBB/nodebb-plugin-composer-quill/50e3e77d1f1081d5744b6ab8bd38d812a1a6f1d4/screenshots/desktop.png
--------------------------------------------------------------------------------
/screenshots/mobile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/NodeBB/nodebb-plugin-composer-quill/50e3e77d1f1081d5744b6ab8bd38d812a1a6f1d4/screenshots/mobile.png
--------------------------------------------------------------------------------
/static/lib/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "nodebb/public"
3 | }
--------------------------------------------------------------------------------
/static/lib/admin.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals app, $, socket, define, bootbox */
4 |
5 | define('admin/plugins/composer-quill', ['benchpress'], function (Benchpress) {
6 | var ACP = {};
7 |
8 | ACP.init = function () {
9 | $('button[data-action="migrate/in"]').on('click', ACP.migrateIn);
10 | $('button[data-action="migrate/out"]').on('click', ACP.migrateOut);
11 | };
12 |
13 | ACP.migrateIn = function () {
14 | Benchpress.parse('plugins/composer-quill/modals/migrate-in', {}, function (html) {
15 | var start = function () {
16 | var modal = this;
17 | var progressEl = modal.find('.progress .progress-bar');
18 | var current = 0;
19 | $(modal).find('.modal-footer button').prop('disabled', true);
20 |
21 | socket.emit('admin.plugins.composer-quill.migrateIn', function (err, ok) {
22 | if (err) {
23 | return app.alertError(err.message);
24 | }
25 |
26 | if (ok) {
27 | // Close modal on migration completion
28 | modal.modal('hide');
29 | }
30 | });
31 |
32 | // Update progress bar on server notice
33 | socket.on('event:composer-quill.migrateUpdate', function (payload) {
34 | var percentage = Math.floor((payload.current / payload.total) * 100);
35 | if (percentage > current) {
36 | progressEl.css('width', percentage + '%');
37 | progressEl.attr('aria-valuenow', percentage);
38 | current = percentage;
39 | }
40 | });
41 |
42 | return false;
43 | };
44 |
45 | bootbox.dialog({
46 | title: 'Migrate Content to Quill',
47 | message: html,
48 | buttons: {
49 | submit: {
50 | label: 'Begin Migration',
51 | className: 'btn-primary',
52 | callback: start,
53 | },
54 | },
55 | });
56 | });
57 | };
58 |
59 | ACP.migrateOut = function () {
60 | Benchpress.parse('plugins/composer-quill/modals/migrate-out', {}, function (html) {
61 | var start = function () {
62 | var modal = this;
63 | var progressEl = modal.find('.progress .progress-bar');
64 | var current = 0;
65 | $(modal).find('.modal-footer button').prop('disabled', true);
66 |
67 | socket.emit('admin.plugins.composer-quill.migrateOut', function (err, ok) {
68 | if (err) {
69 | return app.alertError(err.message);
70 | }
71 |
72 | if (ok) {
73 | // Close modal on migration completion
74 | modal.modal('hide');
75 | }
76 | });
77 |
78 | // Update progress bar on server notice
79 | socket.on('event:composer-quill.migrateUpdate', function (payload) {
80 | var percentage = Math.floor((payload.current / payload.total) * 100);
81 | if (percentage > current) {
82 | progressEl.css('width', percentage + '%');
83 | progressEl.attr('aria-valuenow', percentage);
84 | current = percentage;
85 | }
86 | });
87 |
88 | return false;
89 | };
90 |
91 | bootbox.dialog({
92 | title: 'Reverse previous Quill Migration',
93 | message: html,
94 | buttons: {
95 | submit: {
96 | label: 'Begin Migration',
97 | className: 'btn-primary',
98 | callback: start,
99 | },
100 | },
101 | });
102 | });
103 | };
104 |
105 | return ACP;
106 | });
107 |
--------------------------------------------------------------------------------
/static/lib/client.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals $, document, window */
4 |
5 | $(document).ready(() => {
6 | const wrapWithBlockquote = function (delta) {
7 | // Validate the delta
8 | try {
9 | const parsed = JSON.parse(delta);
10 | parsed.ops = parsed.ops.map((op) => {
11 | // eslint-disable-next-line prefer-object-spread
12 | op.attributes = Object.assign({ blockquote: true }, op.attributes || {});
13 | return op;
14 | });
15 | return JSON.stringify(parsed);
16 | } catch (e) {
17 | // It is probably just a text string, make your own delta(tm)
18 | return JSON.stringify({ ops: [{ insert: `${delta}\n`, attributes: { blockquote: true } }] });
19 | }
20 | };
21 | $(window).on('action:app.load', () => {
22 | require(['composer', 'quill-nbb'], (composer) => {
23 | $(window).on('action:composer.topic.new', (ev, data) => {
24 | composer.newTopic({
25 | cid: data.cid,
26 | title: data.title || '',
27 | body: data.body || '',
28 | tags: data.tags || [],
29 | });
30 | });
31 |
32 | $(window).on('action:composer.post.edit', (ev, data) => {
33 | composer.editPost({ pid: data.pid });
34 | });
35 |
36 | $(window).on('action:composer.post.new', (ev, data) => {
37 | data.body = data.body || data.text;
38 | data.title = data.title || data.topicName;
39 | composer.newReply({
40 | tid: data.tid,
41 | toPid: data.pid,
42 | title: data.title,
43 | body: data.body,
44 | });
45 | });
46 |
47 | $(window).on('action:composer.addQuote', (ev, data) => {
48 | data.title = data.title || data.topicName;
49 | data.body = data.body || data.text;
50 | composer.newReply({
51 | tid: data.tid,
52 | toPid: data.pid,
53 | title: data.title,
54 | body: wrapWithBlockquote(data.body),
55 | });
56 | });
57 | });
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/static/lib/emoji.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals define, socket, app, config */
4 |
5 | /**
6 | * DEVELOPER NOTE
7 | *
8 | * It isn't particularly scalable to have a separate file for integration with
9 | * each individual plugin. Eventually, it is expected that Quill will fire off
10 | * hooks that plugins can listen for and invoke, therefore the code contained
11 | * here would be better located in the emoji plugin instead.
12 | *
13 | * .enable() is called from quill-nbb.js but it could very well be listening
14 | * for action:quill.load
15 | *
16 | * .convert() is called during composer autocomplete, which could be listening
17 | * for a hook to be fired by autocomplete, of which there is none right now.
18 | *
19 | * 2 June 2021 -- core now has client-side hooks, which will allow this to happen.
20 | */
21 |
22 | define('quill-emoji', ['quill'], (quill) => {
23 | const Emoji = {
24 | table: {},
25 | };
26 |
27 | // Emoji Blot
28 | const imageBlot = quill.import('formats/image');
29 | const emojiAttributes = ['src', 'alt', 'class'];
30 |
31 | class EmojiBlot extends imageBlot {
32 | static create(value) {
33 | const node = super.create(value.src);
34 | node.setAttribute('class', value.class);
35 | return node;
36 | }
37 |
38 | static formats(domNode) {
39 | return emojiAttributes.reduce((formats, attribute) => {
40 | if (domNode.hasAttribute(attribute)) {
41 | formats[attribute] = domNode.getAttribute(attribute);
42 | }
43 | return formats;
44 | }, {});
45 | }
46 |
47 | static format(name, value) {
48 | if (emojiAttributes.includes(name)) {
49 | if (value) {
50 | this.domNode.setAttribute(name, value);
51 | } else {
52 | this.domNode.removeAttribute(name);
53 | }
54 | } else {
55 | super.format(name, value);
56 | }
57 | }
58 |
59 | static value(domNode) {
60 | return {
61 | src: domNode.getAttribute('src'),
62 | class: domNode.getAttribute('class'),
63 | };
64 | }
65 | }
66 | EmojiBlot.blotName = 'emoji';
67 | quill.register(EmojiBlot);
68 |
69 | Emoji.enable = function (quill) {
70 | if (!Object.keys(Emoji.table).length) {
71 | socket.emit('plugins.composer-quill.getEmojiTable', {}, (err, table) => {
72 | if (err) {
73 | app.alertError(err.message);
74 | }
75 |
76 | if (table !== null) {
77 | Emoji.table = table;
78 | quill.on('text-change', Emoji.convert.bind(quill));
79 | }
80 | });
81 | }
82 | };
83 |
84 | Emoji.convert = function (delta) {
85 | const quill = this;
86 | const contents = quill.getContents();
87 | const emojiRegex = /:([\w+-]+):/g;
88 |
89 | // Special handling for emoji plugin
90 | if (!delta || delta.ops.some(command => command.insert && (command.insert === ':' || String(command.insert).endsWith(':') || String(command.insert).endsWith(': \n')))) {
91 | // Check all nodes for emoji shorthand and replace with image
92 | contents.reduce((retain, cur) => {
93 | let match = emojiRegex.exec(cur.insert);
94 | let contents;
95 | let emojiObj;
96 | while (match !== null) {
97 | emojiObj = Emoji.table[match[1]];
98 | if (emojiObj) {
99 | contents = [{
100 | insert: {
101 | emoji: {
102 | src: `${config.relative_path}/plugins/nodebb-plugin-emoji/emoji/${emojiObj.pack}/${emojiObj.image}?${app.cacheBuster}`,
103 | class: `not-responsive emoji emoji-${emojiObj.pack} emoji--${emojiObj.name}`,
104 | },
105 | },
106 | attributes: {
107 | alt: emojiObj.character,
108 | },
109 | }];
110 | if (match[0].length) {
111 | contents.unshift({ delete: match[0].length });
112 | }
113 | if (retain + match.index) {
114 | contents.unshift({ retain: retain + match.index });
115 | }
116 |
117 | quill.updateContents({
118 | ops: contents,
119 | });
120 | }
121 |
122 | // Reset search and continue
123 | emojiRegex.lastIndex = retain + match.index + 1;
124 | match = emojiRegex.exec(cur.insert);
125 | }
126 |
127 | retain += cur.insert.length || 1;
128 | return retain;
129 | }, 0);
130 | }
131 | };
132 |
133 | return Emoji;
134 | });
135 |
--------------------------------------------------------------------------------
/static/lib/quill-nbb.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /* globals document, $, window, define, socket, app, ajaxify, config */
4 |
5 | window.quill = {
6 | uploads: {},
7 | };
8 |
9 | define('quill-nbb', [
10 | 'quill',
11 | 'composer/resize',
12 | 'components',
13 | 'slugify',
14 | ], (Quill, resize, components, slugify) => {
15 | $(window).on('action:composer.loaded', (ev, data) => {
16 | const postContainer = $(`.composer[data-uuid="${data.post_uuid}"]`);
17 | const targetEl = postContainer.find('.write-container div');
18 |
19 | window.quill.init(targetEl, data);
20 |
21 | const cidEl = postContainer.find('.category-list');
22 | if (cidEl.length) {
23 | cidEl.attr('id', `cmp-cid-${data.post_uuid}`);
24 | } else {
25 | postContainer.append(``);
26 | }
27 |
28 | // if (config.allowTopicsThumbnail && data.composerData.isMain) {
29 | // var thumbToggleBtnEl = postContainer.find('.re-topic_thumb');
30 | // var url = data.composerData.topic_thumb || '';
31 |
32 | // postContainer.find('input#topic-thumb-url').val(url);
33 | // postContainer.find('img.topic-thumb-preview').attr('src', url);
34 |
35 | // if (url) {
36 | // postContainer.find('.topic-thumb-clear-btn').removeClass('hide');
37 | // }
38 | // thumbToggleBtnEl.addClass('show');
39 | // thumbToggleBtnEl.off('click').on('click', function() {
40 | // var container = postContainer.find('.topic-thumb-container');
41 | // container.toggleClass('hide', !container.hasClass('hide'));
42 | // });
43 | // }
44 |
45 | resize.reposition(postContainer);
46 | });
47 |
48 | $(window).on('action:composer.check', (ev, data) => {
49 | // Update bodyLen for length checking purposes
50 | const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill');
51 | data.bodyLen = quill.getLength() - 1;
52 | });
53 |
54 | $(window).on('action:chat.sent', (evt, data) => {
55 | // Empty chat input
56 | const quill = $(`.chat-modal[data-roomid="${data.roomId}"] .ql-container, .expanded-chat[data-roomid="${data.roomId}"] .ql-container`).data('quill');
57 | quill.deleteText(0, quill.getLength());
58 |
59 | // Reset text direction
60 | const textDirection = $('html').attr('data-dir');
61 | quill.format('direction', textDirection);
62 | quill.format('align', textDirection === 'rtl' ? 'right' : 'left');
63 | });
64 |
65 | $(window).on('action:chat.prepEdit', (evt, data) => {
66 | let value = data.inputEl.val();
67 | const quill = data.inputEl.siblings('.ql-container').data('quill');
68 |
69 | try {
70 | value = JSON.parse(value);
71 | quill.setContents(value, 'user');
72 | } catch (e) {
73 | app.alertError('[[error:invalid-json]]');
74 | }
75 | });
76 |
77 | $(window).on('action:composer.uploadUpdate', (evt, data) => {
78 | const filename = data.filename.replace(/^\d+_\d+_/, '');
79 | const alertId = generateAlertId(data.post_uuid, filename);
80 | if (!window.quill.uploads[filename]) {
81 | console.warn(`[quill/uploads] Unable to find file (${filename}).`);
82 | app.removeAlert(alertId);
83 | return;
84 | }
85 |
86 | if (!data.text.startsWith('/')) {
87 | app.alert({
88 | alert_id: alertId,
89 | title: data.filename.replace(/\d_\d+_/, ''),
90 | message: data.text,
91 | timeout: 1000,
92 | });
93 | }
94 | });
95 |
96 | $(window).on('action:composer.upload', (evt, data) => {
97 | const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill');
98 | data.files.forEach((file) => {
99 | const alertId = generateAlertId(data.post_uuid, file.filename);
100 | app.removeAlert(alertId);
101 |
102 | // Image vs. file upload
103 | if (file.isImage) {
104 | quill.insertEmbed(quill.getSelection().index, 'image', file.url);
105 | } else {
106 | const selection = quill.getSelection();
107 |
108 | if (selection.length) {
109 | const linkText = quill.getText(selection.index, selection.length);
110 | quill.deleteText(selection.index, selection.length);
111 | quill.insertText(selection.index, linkText, {
112 | link: file.url,
113 | });
114 | } else {
115 | quill.insertText(selection.index, file.filename, {
116 | link: file.url,
117 | });
118 | }
119 | }
120 | });
121 | });
122 |
123 | $(window).on('action:composer.uploadError', (evt, data) => {
124 | const quill = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('.ql-container').data('quill');
125 | const textareaEl = components.get('composer').filter(`[data-uuid="${data.post_uuid}"]`).find('textarea');
126 | textareaEl.val(!window.quill.isEmpty(quill) ? JSON.stringify(quill.getContents()) : '');
127 | textareaEl.trigger('change');
128 | textareaEl.trigger('keyup');
129 | });
130 |
131 | $(window).on('action:composer.uploadStart', (evt, data) => {
132 | data.files.forEach((file) => {
133 | app.alert({
134 | alert_id: generateAlertId(data.post_uuid, file.filename),
135 | title: file.filename.replace(/\d_\d+_/, ''),
136 | message: data.text,
137 | });
138 | });
139 | });
140 |
141 | $(window).on('action:composer.insertIntoTextarea', (evt, data) => {
142 | const quill = $(data.textarea).siblings('.ql-container').data('quill');
143 | const selection = quill.getSelection(true);
144 | quill.insertText(selection.index, data.value);
145 | data.preventDefault = true;
146 |
147 | // hack to convert emoji's inserted text into... an emoji
148 | require(['quill-emoji'], (Emoji) => {
149 | Emoji.convert.call(quill);
150 | });
151 | });
152 |
153 | $(window).on('action:composer.updateTextareaSelection', (evt, data) => {
154 | const quill = $(data.textarea).siblings('.ql-container').data('quill');
155 | quill.setSelection(data.start, data.end - data.start);
156 | data.preventDefault = true;
157 | });
158 |
159 | $(window).on('action:composer.wrapSelectionInTextareaWith', (evt, data) => {
160 | const Delta = Quill.import('delta');
161 | const quill = $(data.textarea).siblings('.ql-container').data('quill');
162 |
163 | const range = quill.getSelection();
164 | let insertionDelta;
165 |
166 | if (range.length) {
167 | insertionDelta = quill.getContents(range.index, range.length);
168 | } else {
169 | insertionDelta = new Delta();
170 | }
171 |
172 | // Wrap selection in spoiler tags
173 | quill.updateContents(new Delta()
174 | .retain(range.index)
175 | .delete(range.length)
176 | .insert(data.leading)
177 | .concat(insertionDelta)
178 | .insert(data.trailing));
179 |
180 | if (range.length) {
181 | // Update selection
182 | quill.setSelection(range.index + (data.leading.length), range.length);
183 | }
184 |
185 | data.preventDefault = true;
186 | });
187 |
188 | $(window).on('action:chat.updateRemainingLength', (evt, data) => {
189 | const quill = data.parent.find('.ql-container').data('quill');
190 | const length = quill.getText().length - 1;
191 | data.parent.find('[component="chat/message/length"]').text(length);
192 | data.parent.find('[component="chat/message/remaining"]').text(config.maximumChatMessageLength - length);
193 | });
194 |
195 | function generateAlertId(uuid, filename) {
196 | return slugify([uuid, filename].join('-'));
197 | }
198 | });
199 |
200 | // Window events that must be attached immediately
201 |
202 | $(window).on('action:chat.loaded', (evt, containerEl) => {
203 | require([
204 | 'composer',
205 | 'composer/autocomplete',
206 | 'components',
207 | ], (composer, autocomplete, components) => {
208 | // Create div element for composer
209 | const targetEl = $('
8 | Quill is a free, open source WYSIWYG editor built for the modern web. With its modular architecture and expressive API, it is completely customizable to fit any need. 9 |
10 | 11 |17 | If you are switching to Quill from a different composer (i.e. composer-default/markdown), you will need to convert your existing posts to Quill's format. You may use the utilities below to do so. 18 |
19 | 20 | 21 | 22 |The Markdown plugin is either disabled, or HTML sanitization is disabled
35 | {{{ else }}} 36 | 37 |38 | In order to render post content correctly, the Markdown plugin needs to have HTML sanitization disabled, 39 | or the entire plugin should be disabled altogether. 40 |
41 | {{{ end }}} 42 |Great! Looks like Quill is the only composer active
48 | {{{ else }}} 49 | 50 |Quill must be the only composer active. Please disable other composers and reload NodeBB.
51 | {{{ end }}} 52 |Use this utility to migrate pre-existing post content to a Quill-compatible format
2 |3 | This plugin will convert Markdown and HTML in post content to Quill deltas, the internal format used by the Quill library. The old content will be saved in case you ever want to migrate back (via the "Migrate from Quill" option), but any posts made in Quill will be left as-is. 4 |
5 |6 | Important! → Once begun, the migration process cannot be stopped. 7 |
8 | 9 |Use this utility to reverse any previously migrated content back to its old format
2 |3 | If there were posts that were migrated to Quill via the bundled utility, a backup of the old content was saved. This migrator will restore the old format. Any posts made in Quill will be left as-is. 4 |
5 |6 | Important! → Once begun, the migration process cannot be stopped. 7 |
8 | 9 |