Prepare release 1.4.0 (#1344)

Prepare release 1.4.0
This commit is contained in:
Max Wu 2019-11-11 17:28:00 +08:00 committed by GitHub
commit a0cc1955cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
108 changed files with 10061 additions and 2743 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
.idea
coverage
node_modules/
# ignore config files
config.json
.sequelizerc
# ignore webpack build
public/build
public/views/build
.nyc_output
coverage/

View File

@ -1,3 +0,0 @@
lib/ot
public/vendor
public/build

View File

@ -1,21 +0,0 @@
module.exports = {
"root": true,
"extends": "standard",
"env": {
"node": true
},
"rules": {
// at some point all of these should return to their default "error" state
// but right now, this is not a good choice, because too many places are
// wrong.
"import/first": ["warn"],
"indent": ["warn"],
"no-multiple-empty-lines": ["warn"],
"no-multi-spaces": ["warn"],
"object-curly-spacing": ["warn"],
"one-var": ["warn"],
"quotes": ["warn"],
"semi": ["warn"],
"space-infix-ops": ["warn"]
}
};

2
.gitignore vendored
View File

@ -27,3 +27,5 @@ public/views/build
public/uploads/*
!public/uploads/.gitkeep
/.nyc_output
/coverage/

View File

@ -1,8 +1,9 @@
var path = require('path');
const path = require('path')
const config = require('./lib/config')
module.exports = {
'config': path.resolve('config.json'),
config: path.resolve('config.json'),
'migrations-path': path.resolve('lib', 'migrations'),
'models-path': path.resolve('lib', 'models'),
'url': 'change this'
url: process.env['CMD_DB_URL'] || config.dbURL
}

View File

@ -1,41 +1,34 @@
language: node_js
dist: trusty
node_js:
- "lts/carbon"
- "lts/dubnium"
- "11"
- "12"
dist: xenial
cache: yarn
env:
global:
- CXX=g++-4.8
- YARN_VERSION=1.3.2
matrix:
fast_finish: true
include:
- node_js: lts/carbon
- node_js: lts/dubnium
allow_failures:
- node_js: "11"
- node_js: "12"
script:
- yarn test:ci
- yarn build
jobs:
include:
- env: task=npm-test
node_js:
- 8
before_install:
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
- export PATH="$HOME/.yarn/bin:$PATH"
- env: task=npm-test
node_js:
- 10
before_install:
- curl -o- -L https://yarnpkg.com/install.sh | bash -s -- --version "$YARN_VERSION"
- export PATH="$HOME/.yarn/bin:$PATH"
- env: task=ShellCheck
script:
- shellcheck bin/heroku bin/setup
language: generic
- env: task=doctoc
install: npm install doctoc
- stage: doctoc-check
install: npm install -g doctoc
if: type = pull_request OR branch = master
script:
- cp README.md README.md.orig
- npm run doctoc
- diff -q README.md README.md.orig
language: generic
- env: task=json-lint
addons:
apt:
packages:
- jq
script:
- npm run jsonlint
language: generic
node_js: lts/carbon

View File

@ -9,6 +9,8 @@ CodiMD
CodiMD lets you collaborate in real-time with markdown.
Built on [HackMD](https://hackmd.io) source code, CodiMD lets you host and control your team's content with speed and ease.
![screenshot](https://raw.githubusercontent.com/hackmdio/codimd/develop/public/screenshot.png)
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
# Table of Contents
@ -71,20 +73,11 @@ All contributions are welcome! Even asking a question helps.
## Browser Support
CodiMD is a service that runs on Node.js, while users use the service through browsers. We support your users using the following browsers:
- ![Chrome](http://browserbadge.com/chrome/47/18px)
- Chrome >= 47
- Chrome for Android >= 47
- ![Safari](http://browserbadge.com/safari/9/18px)
- Safari >= 9
- iOS Safari >= 8.4
- ![Firefox](http://browserbadge.com/firefox/44/18px)
- Firefox >= 44
- ![IE](http://browserbadge.com/ie/9/18px)
- IE >= 9
- Edge >= 12
- ![Opera](http://browserbadge.com/opera/34/18px)
- Opera >= 34
- Opera Mini not supported
- <img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" /> Chrome >= 47, Chrome for Android >= 47
- <img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" /> Safari >= 9, iOS Safari >= 8.4
- <img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" /> Firefox >= 44
- <img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" /> IE >= 9, Edge >= 12
- <img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_48x48.png" alt="Opera" width="24px" height="24px" /> Opera >= 34, Opera Mini not supported
- Android Browser >= 4.4
To stay up to date with your installation it's recommended to subscribe the [release feed][github-release-feed].

32
app.js
View File

@ -7,7 +7,6 @@ var ejs = require('ejs')
var passport = require('passport')
var methodOverride = require('method-override')
var cookieParser = require('cookie-parser')
var compression = require('compression')
var session = require('express-session')
var SequelizeStore = require('connect-session-sequelize')(session.Store)
var fs = require('fs')
@ -26,19 +25,17 @@ var response = require('./lib/response')
var models = require('./lib/models')
var csp = require('./lib/csp')
// server setup
var app = express()
var server = null
function createHttpServer () {
if (config.useSSL) {
var ca = (function () {
var i, len, results
results = []
const ca = (function () {
let i, len
const results = []
for (i = 0, len = config.sslCAPath.length; i < len; i++) {
results.push(fs.readFileSync(config.sslCAPath[i], 'utf8'))
}
return results
})()
var options = {
const options = {
key: fs.readFileSync(config.sslKeyPath, 'utf8'),
cert: fs.readFileSync(config.sslCertPath, 'utf8'),
ca: ca,
@ -46,14 +43,19 @@ if (config.useSSL) {
requestCert: false,
rejectUnauthorized: false
}
server = require('https').createServer(options, app)
return require('https').createServer(options, app)
} else {
server = require('http').createServer(app)
return require('http').createServer(app)
}
}
// server setup
var app = express()
var server = createHttpServer()
// logger
app.use(morgan('combined', {
'stream': logger.stream
stream: logger.stream
}))
// socket io
@ -77,9 +79,6 @@ var sessionStore = new SequelizeStore({
db: models.sequelize
})
// compression
app.use(compression())
// use hsts to tell https users stick to this
if (config.hsts.enable) {
app.use(helmet.hsts({
@ -181,6 +180,7 @@ app.locals.serverURL = config.serverURL
app.locals.sourceURL = config.sourceURL
app.locals.allowAnonymous = config.allowAnonymous
app.locals.allowAnonymousEdits = config.allowAnonymousEdits
app.locals.permission = config.permission
app.locals.allowPDFExport = config.allowPDFExport
app.locals.authProviders = {
facebook: config.isFacebookEnable,
@ -279,6 +279,7 @@ process.on('uncaughtException', function (err) {
function handleTermSignals () {
logger.info('CodiMD has been killed by signal, try to exit gracefully...')
realtime.maintenance = true
realtime.terminate()
// disconnect all socket.io clients
Object.keys(io.sockets.sockets).forEach(function (key) {
var socket = io.sockets.sockets[key]
@ -299,6 +300,9 @@ function handleTermSignals () {
})
}
}, 100)
setTimeout(() => {
process.exit(1)
}, 5000)
}
process.on('SIGINT', handleTermSignals)
process.on('SIGTERM', handleTermSignals)

View File

@ -11,18 +11,10 @@
"logo": "https://github.com/hackmdio/codimd/raw/master/public/codimd-icon-1024.png",
"success_url": "/",
"env": {
"BUILD_ASSETS": {
"description": "Our build script variable",
"value": "true"
},
"NPM_CONFIG_PRODUCTION": {
"description": "Let npm also install development build tool",
"value": "false"
},
"DB_TYPE": {
"description": "Specify database type. See sequelize available databases. Default using postgres",
"value": "postgres"
},
"HMD_SESSION_SECRET": {
"description": "Secret used to secure session cookies.",
"required": false

View File

@ -2,7 +2,7 @@
set -e
if [ "$BUILD_ASSETS" = true ]; then
if [ ! -z "$DYNO" ]; then
# setup config files
cat << EOF > .sequelizerc
var path = require('path');
@ -11,8 +11,7 @@ module.exports = {
'config': path.resolve('config.json'),
'migrations-path': path.resolve('lib', 'migrations'),
'models-path': path.resolve('lib', 'models'),
'url': process.env.DATABASE_URL,
'dialect': process.env.DB_TYPE
'url': process.env.DATABASE_URL
}
EOF
@ -26,6 +25,4 @@ EOF
EOF
# build app
npm run build
fi

View File

@ -3,7 +3,8 @@
"db": {
"dialect": "sqlite",
"storage": ":memory:"
}
},
"linkifyHeaderStyle": "gfm"
},
"development": {
"loglevel": "debug",
@ -13,7 +14,8 @@
"db": {
"dialect": "sqlite",
"storage": "./db.codimd.sqlite"
}
},
"linkifyHeaderStyle": "gfm"
},
"production": {
"domain": "localhost",
@ -123,6 +125,11 @@
{
"connectionString": "change this",
"container": "change this"
}
},
"plantuml":
{
"server": "https://www.plantuml.com/plantuml"
},
"linkifyHeaderStyle": "gfm"
}
}

23
deployments/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM hackmdio/buildpack:1.0.4 as BUILD
COPY --chown=hackmd:hackmd . .
RUN set -xe && \
git reset --hard && \
git clean -fx && \
yarn install && \
yarn build && \
yarn install --production=true && \
cp ./deployments/docker-entrypoint.sh ./ && \
cp .sequelizerc.example .sequelizerc && \
rm -rf .git .gitignore .travis.yml .dockerignore .editorconfig .babelrc .mailmap .sequelizerc.example \
test docs contribute \
yarn.lock webpack.prod.js webpack.htmlexport.js webpack.dev.js webpack.common.js \
config.json.example README.md CONTRIBUTING.md AUTHORS
FROM hackmdio/runtime:1.0.4
USER hackmd
WORKDIR /home/hackmd/app
COPY --chown=1500:1500 --from=BUILD /home/hackmd/app .
EXPOSE 3000
ENTRYPOINT ["/home/hackmd/app/docker-entrypoint.sh"]

5
deployments/build.sh Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
CURRENT_DIR=$(dirname "$BASH_SOURCE")
docker build -t hackmdio/codimd -f "$CURRENT_DIR/Dockerfile" "$CURRENT_DIR/.."

View File

@ -0,0 +1,28 @@
version: "3"
services:
database:
image: postgres:11.5
environment:
- POSTGRES_USER=codimd
- POSTGRES_PASSWORD=change_password
- POSTGRES_DB=codimd
volumes:
- "database-data:/var/lib/postgresql/data"
restart: always
codimd:
build:
context: ..
dockerfile: ./deployments/Dockerfile
environment:
- CMD_DB_URL=postgres://codimd:change_password@database/codimd
- CMD_USECDN=false
depends_on:
- database
ports:
- "3000:3000"
volumes:
- upload-data:/home/hackmd/app/public/uploads
restart: always
volumes:
database-data: {}
upload-data: {}

View File

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -gt 0 ]]; then
exec "$@"
exit $?
fi
# check database and redis is ready
pcheck -constr "$CMD_DB_URL"
# run DB migrate
NEED_MIGRATE=${CMD_AUTO_MIGRATE:=true}
if [[ "$NEED_MIGRATE" = "true" ]] && [[ -f .sequelizerc ]] ; then
npx sequelize db:migrate
fi
# start application
node app.js

View File

@ -31,6 +31,7 @@ module.exports = {
useCDN: true,
allowAnonymous: true,
allowAnonymousEdits: false,
allowAnonymousViews: true,
allowFreeURL: false,
forbiddenNoteIDs: ['robots.txt', 'favicon.ico', 'api'],
defaultPermission: 'editable',
@ -60,8 +61,11 @@ module.exports = {
responseMaxLag: 70,
// document
documentMaxLength: 100000,
// image upload setting, available options are imgur/s3/filesystem/azure
// image upload setting, available options are imgur/s3/filesystem/azure/lutim
imageUploadType: 'filesystem',
lutim: {
url: 'https://framapic.org/'
},
imgur: {
clientID: undefined
},
@ -100,6 +104,7 @@ module.exports = {
consumerSecret: undefined
},
github: {
enterpriseURL: undefined, // if you use github.com, not need to specify
clientID: undefined,
clientSecret: undefined
},
@ -151,9 +156,27 @@ module.exports = {
email: undefined
}
},
plantuml: {
server: 'https://www.plantuml.com/plantuml'
},
email: true,
allowEmailRegister: true,
allowGravatar: true,
allowPDFExport: true,
openID: false
openID: false,
defaultUseHardbreak: true,
// linkifyHeaderStyle - How is a header text converted into a link id.
// Header Example: "3.1. Good Morning my Friend! - Do you have 5$?"
// * 'keep-case' is the legacy CodiMD value.
// Generated id: "31-Good-Morning-my-Friend---Do-you-have-5"
// * 'lower-case' is the same like legacy (see above), but converted to lower-case.
// Generated id: "#31-good-morning-my-friend---do-you-have-5"
// * 'gfm' _GitHub-Flavored Markdown_ style as described here:
// https://gist.github.com/asabaylus/3071099#gistcomment-1593627
// It works like 'lower-case', but making sure the ID is unique.
// This is What GitHub, GitLab and (hopefully) most other tools use.
// Generated id: "31-good-morning-my-friend---do-you-have-5"
// 2nd appearance: "31-good-morning-my-friend---do-you-have-5-1"
// 3rd appearance: "31-good-morning-my-friend---do-you-have-5-2"
linkifyHeaderStyle: 'keep-case'
}

View File

@ -27,6 +27,7 @@ module.exports = {
useCDN: toBooleanConfig(process.env.CMD_USECDN),
allowAnonymous: toBooleanConfig(process.env.CMD_ALLOW_ANONYMOUS),
allowAnonymousEdits: toBooleanConfig(process.env.CMD_ALLOW_ANONYMOUS_EDITS),
allowAnonymousViews: toBooleanConfig(process.env.CMD_ALLOW_ANONYMOUS_VIEWS),
allowFreeURL: toBooleanConfig(process.env.CMD_ALLOW_FREEURL),
forbiddenNoteIDs: toArrayConfig(process.env.CMD_FORBIDDEN_NOTE_IDS),
defaultPermission: process.env.CMD_DEFAULT_PERMISSION,
@ -65,6 +66,7 @@ module.exports = {
consumerSecret: process.env.CMD_TWITTER_CONSUMERSECRET
},
github: {
enterpriseURL: process.env.CMD_GITHUB_ENTERPRISE_URL,
clientID: process.env.CMD_GITHUB_CLIENTID,
clientSecret: process.env.CMD_GITHUB_CLIENTSECRET
},
@ -127,9 +129,14 @@ module.exports = {
email: process.env.CMD_SAML_ATTRIBUTE_EMAIL
}
},
plantuml: {
server: process.env.CMD_PLANTUML_SERVER
},
email: toBooleanConfig(process.env.CMD_EMAIL),
allowEmailRegister: toBooleanConfig(process.env.CMD_ALLOW_EMAIL_REGISTER),
allowGravatar: toBooleanConfig(process.env.CMD_ALLOW_GRAVATAR),
allowPDFExport: toBooleanConfig(process.env.CMD_ALLOW_PDF_EXPORT),
openID: toBooleanConfig(process.env.CMD_OPENID)
openID: toBooleanConfig(process.env.CMD_OPENID),
defaultUseHardbreak: toBooleanConfig(process.env.CMD_DEFAULT_USE_HARD_BREAK),
linkifyHeaderStyle: process.env.CMD_LINKIFY_HEADER_STYLE
}

View File

@ -53,14 +53,14 @@ if (['debug', 'verbose', 'info', 'warn', 'error'].includes(config.loglevel)) {
// load LDAP CA
if (config.ldap.tlsca) {
let ca = config.ldap.tlsca.split(',')
let caContent = []
for (let i of ca) {
if (fs.existsSync(i)) {
caContent.push(fs.readFileSync(i, 'utf8'))
const certificateAuthorities = config.ldap.tlsca.split(',')
const caContent = []
for (const ca of certificateAuthorities) {
if (fs.existsSync(ca)) {
caContent.push(fs.readFileSync(ca, 'utf8'))
}
}
let tlsOptions = {
const tlsOptions = {
ca: caContent
}
config.ldap.tlsOptions = config.ldap.tlsOptions ? Object.assign(config.ldap.tlsOptions, tlsOptions) : tlsOptions
@ -68,11 +68,17 @@ if (config.ldap.tlsca) {
// Permission
config.permission = Permission
if (!config.allowAnonymous && !config.allowAnonymousEdits) {
let defaultPermission = config.permission.editable
if (!config.allowAnonymous && !config.allowAnonymousViews) {
delete config.permission.freely
delete config.permission.editable
delete config.permission.locked
defaultPermission = config.permission.limited
} else if (!config.allowAnonymous && !config.allowAnonymousEdits) {
delete config.permission.freely
}
if (!(config.defaultPermission in config.permission)) {
config.defaultPermission = config.permission.editable
config.defaultPermission = defaultPermission
}
// cache result, cannot change config in runtime!!!
@ -134,10 +140,10 @@ config.isGitlabSnippetsEnable = (!config.gitlab.scope || config.gitlab.scope ===
config.updateI18nFiles = (env === Environment.development)
// merge legacy values
let keys = Object.keys(config)
const keys = Object.keys(config)
const uppercase = /[A-Z]/
for (let i = keys.length; i--;) {
let lowercaseKey = keys[i].toLowerCase()
const lowercaseKey = keys[i].toLowerCase()
// if the config contains uppercase letters
// and a lowercase version of this setting exists
// and the config with uppercase is not set
@ -164,8 +170,8 @@ if (config.sessionSecret === 'secret') {
}
// Validate upload upload providers
if (['filesystem', 's3', 'minio', 'imgur', 'azure'].indexOf(config.imageUploadType) === -1) {
logger.error('"imageuploadtype" is not correctly set. Please use "filesystem", "s3", "minio", "azure" or "imgur". Defaulting to "filesystem"')
if (['filesystem', 's3', 'minio', 'imgur', 'azure', 'lutim'].indexOf(config.imageUploadType) === -1) {
logger.error('"imageuploadtype" is not correctly set. Please use "filesystem", "s3", "minio", "azure", "lutim" or "imgur". Defaulting to "filesystem"')
config.imageUploadType = 'filesystem'
}
@ -194,6 +200,7 @@ config.sslCAPath.forEach(function (capath, i, array) {
array[i] = path.resolve(appRootPath, capath)
})
config.appRootPath = appRootPath
config.sslCertPath = path.resolve(appRootPath, config.sslCertPath)
config.sslKeyPath = path.resolve(appRootPath, config.sslKeyPath)
config.dhParamPath = path.resolve(appRootPath, config.dhParamPath)

View File

@ -9,14 +9,6 @@ var logger = require('./logger')
var response = require('./response')
var models = require('./models')
// public
var History = {
historyGet: historyGet,
historyPost: historyPost,
historyDelete: historyDelete,
updateHistory: updateHistory
}
function getHistory (userid, callback) {
models.User.findOne({
where: {
@ -41,7 +33,7 @@ function getHistory (userid, callback) {
continue
}
try {
let id = LZString.decompressFromBase64(history[i].id)
const id = LZString.decompressFromBase64(history[i].id)
if (id && models.Note.checkNoteIdValid(id)) {
history[i].id = models.Note.encodeNoteId(id)
}
@ -200,4 +192,8 @@ function historyDelete (req, res) {
}
}
module.exports = History
// public
exports.historyGet = historyGet
exports.historyPost = historyPost
exports.historyDelete = historyDelete
exports.updateHistory = updateHistory

View File

@ -32,9 +32,9 @@ exports.generateAvatarURL = function (name, email = '', big = true) {
}
name = encodeURIComponent(name)
let hash = crypto.createHash('md5')
const hash = crypto.createHash('md5')
hash.update(email.toLowerCase())
let hexDigest = hash.digest('hex')
const hexDigest = hash.digest('hex')
if (email !== '' && config.allowGravatar) {
photo = 'https://www.gravatar.com/avatar/' + hexDigest

View File

@ -165,15 +165,15 @@ module.exports = function (sequelize, DataTypes) {
}
Note.encodeNoteId = function (id) {
// remove dashes in UUID and encode in url-safe base64
let str = id.replace(/-/g, '')
let hexStr = Buffer.from(str, 'hex')
const str = id.replace(/-/g, '')
const hexStr = Buffer.from(str, 'hex')
return base64url.encode(hexStr)
}
Note.decodeNoteId = function (encodedId) {
// decode from url-safe base64
let id = base64url.toBuffer(encodedId).toString('hex')
const id = base64url.toBuffer(encodedId).toString('hex')
// add dashes between the UUID string parts
let idParts = []
const idParts = []
idParts.push(id.substr(0, 8))
idParts.push(id.substr(8, 4))
idParts.push(id.substr(12, 4))
@ -196,7 +196,7 @@ module.exports = function (sequelize, DataTypes) {
}
}).then(function (note) {
if (note) {
let filePath = path.join(config.docsPath, noteId + '.md')
const filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) {
// if doc in filesystem have newer modified time than last change time
// then will update the doc in db
@ -421,20 +421,20 @@ module.exports = function (sequelize, DataTypes) {
if (ot.TextOperation.isRetain(op)) {
index += op
} else if (ot.TextOperation.isInsert(op)) {
let opStart = index
let opEnd = index + op.length
var inserted = false
const opStart = index
const opEnd = index + op.length
let inserted = false
// authorship format: [userId, startPos, endPos, createdAt, updatedAt]
if (authorships.length <= 0) authorships.push([userId, opStart, opEnd, timestamp, timestamp])
else {
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
const authorship = authorships[j]
if (!inserted) {
let nextAuthorship = authorships[j + 1] || -1
const nextAuthorship = authorships[j + 1] || -1
if ((nextAuthorship !== -1 && nextAuthorship[1] >= opEnd) || j >= authorships.length - 1) {
if (authorship[1] < opStart && authorship[2] > opStart) {
// divide
let postLength = authorship[2] - opStart
const postLength = authorship[2] - opStart
authorship[2] = opStart
authorship[4] = timestamp
authorships.splice(j + 1, 0, [userId, opStart, opEnd, timestamp, timestamp])
@ -460,13 +460,13 @@ module.exports = function (sequelize, DataTypes) {
}
index += op.length
} else if (ot.TextOperation.isDelete(op)) {
let opStart = index
let opEnd = index - op
const opStart = index
const opEnd = index - op
if (operation.length === 1) {
authorships = []
} else if (authorships.length > 0) {
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
const authorship = authorships[j]
if (authorship[1] >= opStart && authorship[1] <= opEnd && authorship[2] >= opStart && authorship[2] <= opEnd) {
authorships.splice(j, 1)
j -= 1
@ -491,12 +491,12 @@ module.exports = function (sequelize, DataTypes) {
}
// merge
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
const authorship = authorships[j]
for (let k = j + 1; k < authorships.length; k++) {
let nextAuthorship = authorships[k]
const nextAuthorship = authorships[k]
if (nextAuthorship && authorship[0] === nextAuthorship[0] && authorship[2] === nextAuthorship[1]) {
let minTimestamp = Math.min(authorship[3], nextAuthorship[3])
let maxTimestamp = Math.max(authorship[3], nextAuthorship[3])
const minTimestamp = Math.min(authorship[3], nextAuthorship[3])
const maxTimestamp = Math.max(authorship[3], nextAuthorship[3])
authorships.splice(j, 1, [authorship[0], authorship[1], nextAuthorship[2], minTimestamp, maxTimestamp])
authorships.splice(k, 1)
j -= 1
@ -506,7 +506,7 @@ module.exports = function (sequelize, DataTypes) {
}
// clear
for (let j = 0; j < authorships.length; j++) {
let authorship = authorships[j]
const authorship = authorships[j]
if (!authorship[0]) {
authorships.splice(j, 1)
j -= 1
@ -537,11 +537,11 @@ module.exports = function (sequelize, DataTypes) {
var lengthBias = 0
for (let j = 0; j < patch.length; j++) {
var operation = []
let p = patch[j]
const p = patch[j]
var currIndex = p.start1
var currLength = contentLength - bias
for (let i = 0; i < p.diffs.length; i++) {
let diff = p.diffs[i]
const diff = p.diffs[i]
switch (diff[0]) {
case 0: // retain
if (i === 0) {

View File

@ -103,7 +103,8 @@ module.exports = function (sequelize, DataTypes) {
else photo += '?size=bigger'
break
case 'github':
photo = 'https://avatars.githubusercontent.com/u/' + profile.id
if (profile.photos && profile.photos[0]) photo = profile.photos[0].value.replace('?', '')
else photo = 'https://avatars.githubusercontent.com/u/' + profile.id
if (bigger) photo += '?s=400'
else photo += '?s=96'
break

106
lib/processQueue.js Normal file
View File

@ -0,0 +1,106 @@
'use strict'
const EventEmitter = require('events').EventEmitter
/**
* Queuing Class for connection queuing
*/
const QueueEvent = {
Tick: 'Tick',
Push: 'Push',
Finish: 'Finish'
}
class ProcessQueue extends EventEmitter {
constructor ({
maximumLength = 500,
triggerTimeInterval = 5000,
// execute on push
proactiveMode = true,
// execute next work on finish
continuousMode = true
}) {
super()
this.max = maximumLength
this.triggerTime = triggerTimeInterval
this.taskMap = new Map()
this.queue = []
this.lock = false
this.on(QueueEvent.Tick, this.onEventProcessFunc.bind(this))
if (proactiveMode) {
this.on(QueueEvent.Push, this.onEventProcessFunc.bind(this))
}
if (continuousMode) {
this.on(QueueEvent.Finish, this.onEventProcessFunc.bind(this))
}
}
onEventProcessFunc () {
if (this.lock) return
this.lock = true
setImmediate(() => {
this.process()
})
}
start () {
if (this.eventTrigger) return
this.eventTrigger = setInterval(() => {
this.emit(QueueEvent.Tick)
}, this.triggerTime)
}
stop () {
if (this.eventTrigger) {
clearInterval(this.eventTrigger)
this.eventTrigger = null
}
}
checkTaskIsInQueue (id) {
return this.taskMap.has(id)
}
/**
* pushWithKey a promisify-task to queue
* @param id {string}
* @param processingFunc {Function<Promise>}
* @returns {boolean} if success return true, otherwise false
*/
push (id, processingFunc) {
if (this.queue.length >= this.max) return false
if (this.checkTaskIsInQueue(id)) return false
const task = {
id: id,
processingFunc: processingFunc
}
this.taskMap.set(id, true)
this.queue.push(task)
this.start()
this.emit(QueueEvent.Push)
return true
}
process () {
if (this.queue.length <= 0) {
this.stop()
this.lock = false
return
}
const task = this.queue.shift()
this.taskMap.delete(task.id)
const finishTask = () => {
this.lock = false
setImmediate(() => {
this.emit(QueueEvent.Finish)
})
}
task.processingFunc().then(finishTask).catch(finishTask)
}
}
exports.ProcessQueue = ProcessQueue

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
'use strict'
const async = require('async')
const config = require('./config')
const logger = require('./logger')
/**
* clean when user not in any rooms or user not in connected list
*/
class CleanDanglingUserJob {
constructor (realtime) {
this.realtime = realtime
}
start () {
if (this.timer) return
this.timer = setInterval(this.cleanDanglingUser.bind(this), 60000)
}
stop () {
if (!this.timer) return
clearInterval(this.timer)
this.timer = undefined
}
cleanDanglingUser () {
const users = this.realtime.getUserPool()
async.each(Object.keys(users), (key, callback) => {
const socket = this.realtime.io.sockets.connected[key]
if ((!socket && users[key]) ||
(socket && (!socket.rooms || socket.rooms.length <= 0))) {
if (config.debug) {
logger.info('cleaner found redundant user: ' + key)
}
if (!socket) {
return callback(null, null)
}
if (!this.realtime.disconnectProcessQueue.checkTaskIsInQueue(socket.id)) {
this.realtime.queueForDisconnect(socket)
}
}
return callback(null, null)
}, function (err) {
if (err) return logger.error('cleaner error', err)
})
}
}
exports.CleanDanglingUserJob = CleanDanglingUserJob

View File

@ -0,0 +1,245 @@
'use strict'
const get = require('lodash/get')
const config = require('./config')
const models = require('./models')
const logger = require('./logger')
class RealtimeClientConnection {
constructor (socket) {
this.socket = socket
this.realtime = require('./realtime')
}
registerEventHandler () {
// received client refresh request
this.socket.on('refresh', this.refreshEventHandler.bind(this))
// received user status
this.socket.on('user status', this.userStatusEventHandler.bind(this))
// when a new client disconnect
this.socket.on('disconnect', this.disconnectEventHandler.bind(this))
// received cursor focus
this.socket.on('cursor focus', this.cursorFocusEventHandler.bind(this))
// received cursor activity
this.socket.on('cursor activity', this.cursorActivityEventHandler.bind(this))
// received cursor blur
this.socket.on('cursor blur', this.cursorBlurEventHandler.bind(this))
// check version
this.socket.on('version', this.checkVersionEventHandler.bind(this))
// received sync of online users request
this.socket.on('online users', this.onlineUsersEventHandler.bind(this))
// reveiced when user logout or changed
this.socket.on('user changed', this.userChangedEventHandler.bind(this))
// delete a note
this.socket.on('delete', this.deleteNoteEventHandler.bind(this))
// received note permission change request
this.socket.on('permission', this.permissionChangeEventHandler.bind(this))
}
isUserLoggedIn () {
return this.socket.request.user && this.socket.request.user.logged_in
}
isNoteAndUserExists () {
const note = this.realtime.getNoteFromNotePool(this.socket.noteId)
const user = this.realtime.getUserFromUserPool(this.socket.id)
return note && user
}
isNoteOwner () {
const note = this.getCurrentNote()
return get(note, 'owner') === this.getCurrentLoggedInUserId()
}
isAnonymousEnable () {
// TODO: move this method to config module
return config.allowAnonymous || config.allowAnonymousEdits
}
getAvailablePermissions () {
// TODO: move this method to config module
const availablePermission = Object.assign({}, config.permission)
if (!config.allowAnonymous && !config.allowAnonymousViews) {
delete availablePermission.freely
delete availablePermission.editable
delete availablePermission.locked
} else if (!config.allowAnonymous && !config.allowAnonymousEdits) {
delete availablePermission.freely
}
return availablePermission
}
getCurrentUser () {
if (!this.socket.id) return
return this.realtime.getUserFromUserPool(this.socket.id)
}
getCurrentLoggedInUserId () {
return get(this.socket, 'request.user.id')
}
getCurrentNote () {
if (!this.socket.noteId) return
return this.realtime.getNoteFromNotePool(this.socket.noteId)
}
getNoteChannel () {
return this.socket.broadcast.to(this.socket.noteId)
}
async destroyNote (id) {
return models.Note.destroy({
where: { id: id }
})
}
async changeNotePermission (newPermission) {
const [changedRows] = await models.Note.update({
permission: newPermission
}, {
where: {
id: this.getCurrentNote().id
}
})
if (changedRows !== 1) {
throw new Error(`updated permission failed, cannot set permission ${newPermission} to note ${this.getCurrentNote().id}`)
}
}
notifyPermissionChanged () {
this.realtime.io.to(this.getCurrentNote().id).emit('permission', {
permission: this.getCurrentNote().permission
})
this.getCurrentNote().socks.forEach((sock) => {
if (sock) {
if (!this.realtime.checkViewPermission(sock.request, this.getCurrentNote())) {
sock.emit('info', {
code: 403
})
setTimeout(function () {
sock.disconnect(true)
}, 0)
}
}
})
}
refreshEventHandler () {
this.realtime.emitRefresh(this.socket)
}
checkVersionEventHandler () {
this.socket.emit('version', {
version: config.fullversion,
minimumCompatibleVersion: config.minimumCompatibleVersion
})
}
userStatusEventHandler (data) {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
if (config.debug) {
logger.info('SERVER received [' + this.socket.noteId + '] user status from [' + this.socket.id + ']: ' + JSON.stringify(data))
}
if (data) {
user.idle = data.idle
user.type = data.type
}
this.realtime.emitUserStatus(this.socket)
}
userChangedEventHandler () {
logger.info('user changed')
const note = this.getCurrentNote()
if (!note) return
const user = note.users[this.socket.id]
if (!user) return
this.realtime.updateUserData(this.socket, user)
this.realtime.emitOnlineUsers(this.socket)
}
onlineUsersEventHandler () {
if (!this.isNoteAndUserExists()) return
const currentNote = this.getCurrentNote()
const currentNoteOnlineUserList = Object.keys(currentNote.users)
.map(key => this.realtime.buildUserOutData(currentNote.users[key]))
this.socket.emit('online users', {
users: currentNoteOnlineUserList
})
}
cursorFocusEventHandler (data) {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
user.cursor = data
const out = this.realtime.buildUserOutData(user)
this.getNoteChannel().emit('cursor focus', out)
}
cursorActivityEventHandler (data) {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
user.cursor = data
const out = this.realtime.buildUserOutData(user)
this.getNoteChannel().emit('cursor activity', out)
}
cursorBlurEventHandler () {
if (!this.isNoteAndUserExists()) return
const user = this.getCurrentUser()
user.cursor = null
this.getNoteChannel().emit('cursor blur', {
id: this.socket.id
})
}
deleteNoteEventHandler () {
// need login to do more actions
if (this.isUserLoggedIn() && this.isNoteAndUserExists()) {
const note = this.getCurrentNote()
// Only owner can delete note
if (note.owner && note.owner === this.getCurrentLoggedInUserId()) {
this.destroyNote(note.id)
.then((successRows) => {
if (!successRows) return
this.realtime.disconnectSocketOnNote(note)
})
.catch(function (err) {
return logger.error('delete note failed: ' + err)
})
}
}
}
permissionChangeEventHandler (permission) {
if (!this.isUserLoggedIn()) return
if (!this.isNoteAndUserExists()) return
const note = this.getCurrentNote()
// Only owner can change permission
if (!this.isNoteOwner()) return
if (!(permission in this.getAvailablePermissions())) return
this.changeNotePermission(permission)
.then(() => {
note.permission = permission
this.notifyPermissionChanged()
})
.catch(err => logger.error('update note permission failed: ' + err))
}
disconnectEventHandler () {
if (this.realtime.disconnectProcessQueue.checkTaskIsInQueue(this.socket.id)) {
return
}
this.realtime.queueForDisconnect(this.socket)
}
}
exports.RealtimeClientConnection = RealtimeClientConnection

View File

@ -0,0 +1,45 @@
'use strict'
const models = require('./models')
const logger = require('./logger')
/**
* clean when user not in any rooms or user not in connected list
*/
class SaveRevisionJob {
constructor (realtime) {
this.realtime = realtime
this.saverSleep = false
}
start () {
if (this.timer) return
this.timer = setInterval(this.saveRevision.bind(this), 5 * 60 * 1000)
}
stop () {
if (!this.timer) return
clearInterval(this.timer)
this.timer = undefined
}
saveRevision () {
if (this.getSaverSleep()) return
models.Revision.saveAllNotesRevision((err, notes) => {
if (err) return logger.error('revision saver failed: ' + err)
if (notes && notes.length <= 0) {
this.setSaverSleep(true)
}
})
}
getSaverSleep () {
return this.saverSleep
}
setSaverSleep (val) {
this.saverSleep = val
}
}
exports.SaveRevisionJob = SaveRevisionJob

View File

@ -0,0 +1,78 @@
'use strict'
const config = require('./config')
const logger = require('./logger')
const moment = require('moment')
class UpdateDirtyNoteJob {
constructor (realtime) {
this.realtime = realtime
}
start () {
if (this.timer) return
this.timer = setInterval(this.updateDirtyNotes.bind(this), 1000)
}
stop () {
if (!this.timer) return
clearInterval(this.timer)
this.timer = undefined
}
updateDirtyNotes () {
const notes = this.realtime.getNotePool()
Object.keys(notes).forEach((key) => {
const note = notes[key]
this.updateDirtyNote(note)
.catch((err) => {
logger.error('updateDirtyNote: updater error', err)
})
})
}
async updateDirtyNote (note) {
const notes = this.realtime.getNotePool()
if (!note.server.isDirty) return
if (config.debug) logger.info('updateDirtyNote: updater found dirty note: ' + note.id)
note.server.isDirty = false
try {
const _note = await this.updateNoteAsync(note)
// handle when note already been clean up
if (!notes[note.id] || !notes[note.id].server) return
if (!_note) {
this.realtime.io.to(note.id).emit('info', {
code: 404
})
logger.error('updateDirtyNote: note not found: ', note.id)
this.realtime.disconnectSocketOnNote(note)
}
note.updatetime = moment(_note.lastchangeAt).valueOf()
this.realtime.emitCheck(note)
} catch (err) {
logger.error('updateDirtyNote: note not found: ', note.id)
this.realtime.io.to(note.id).emit('info', {
code: 404
})
this.realtime.disconnectSocketOnNote(note)
throw err
}
}
updateNoteAsync (note) {
return new Promise((resolve, reject) => {
this.realtime.updateNote(note, (err, _note) => {
if (err) {
return reject(err)
}
return resolve(_note)
})
})
}
}
exports.UpdateDirtyNoteJob = UpdateDirtyNoteJob

View File

@ -1,24 +1,40 @@
'use strict'
// response
// external modules
var fs = require('fs')
var path = require('path')
var markdownpdf = require('markdown-pdf')
var shortId = require('shortid')
var querystring = require('querystring')
var request = require('request')
var moment = require('moment')
const fs = require('fs')
const path = require('path')
const markdownpdf = require('markdown-pdf')
const shortId = require('shortid')
const querystring = require('querystring')
const request = require('request')
const moment = require('moment')
// core
var config = require('./config')
var logger = require('./logger')
var models = require('./models')
var utils = require('./utils')
var history = require('./history')
const config = require('./config')
const logger = require('./logger')
const models = require('./models')
const utils = require('./utils')
const history = require('./history')
// public
var response = {
errorForbidden: function (res) {
exports.errorForbidden = errorForbidden
exports.errorNotFound = errorNotFound
exports.errorBadRequest = errorBadRequest
exports.errorTooLong = errorTooLong
exports.errorInternalError = errorInternalError
exports.errorServiceUnavailable = errorServiceUnavailable
exports.newNote = newNote
exports.showNote = showNote
exports.showPublishNote = showPublishNote
exports.showPublishSlide = showPublishSlide
exports.showIndex = showIndex
exports.noteActions = noteActions
exports.publishNoteActions = publishNoteActions
exports.publishSlideActions = publishSlideActions
exports.githubActions = githubActions
exports.gitlabActions = gitlabActions
function errorForbidden (res) {
const { req } = res
if (req.user) {
responseError(res, '403', 'Forbidden', 'oh no.')
@ -26,32 +42,21 @@ var response = {
req.flash('error', 'You are not allowed to access this page. Maybe try logging in?')
res.redirect(config.serverURL + '/')
}
},
errorNotFound: function (res) {
}
function errorNotFound (res) {
responseError(res, '404', 'Not Found', 'oops.')
},
errorBadRequest: function (res) {
}
function errorBadRequest (res) {
responseError(res, '400', 'Bad Request', 'something not right.')
},
errorTooLong: function (res) {
}
function errorTooLong (res) {
responseError(res, '413', 'Payload Too Large', 'Shorten your note!')
},
errorInternalError: function (res) {
}
function errorInternalError (res) {
responseError(res, '500', 'Internal Error', 'wtf.')
},
errorServiceUnavailable: function (res) {
}
function errorServiceUnavailable (res) {
res.status(503).send("I'm busy right now, try again later.")
},
newNote: newNote,
showNote: showNote,
showPublishNote: showPublishNote,
showPublishSlide: showPublishSlide,
showIndex: showIndex,
noteActions: noteActions,
publishNoteActions: publishNoteActions,
publishSlideActions: publishSlideActions,
githubActions: githubActions,
gitlabActions: gitlabActions
}
function responseError (res, code, detail, msg) {
@ -117,7 +122,7 @@ function newNote (req, res, next) {
var owner = null
var body = ''
if (req.body && req.body.length > config.documentMaxLength) {
return response.errorTooLong(res)
return errorTooLong(res)
} else if (req.body) {
body = req.body
}
@ -125,7 +130,7 @@ function newNote (req, res, next) {
if (req.isAuthenticated()) {
owner = req.user.id
} else if (!config.allowAnonymous) {
return response.errorForbidden(res)
return errorForbidden(res)
}
models.Note.create({
ownerId: owner,
@ -139,7 +144,7 @@ function newNote (req, res, next) {
return res.redirect(config.serverURL + '/' + models.Note.encodeNoteId(note.id))
}).catch(function (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
})
}
@ -159,7 +164,7 @@ function findNote (req, res, callback, include) {
models.Note.parseNoteId(id, function (err, _id) {
if (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
}
models.Note.findOne({
where: {
@ -172,17 +177,17 @@ function findNote (req, res, callback, include) {
req.alias = noteId
return newNote(req, res)
} else {
return response.errorNotFound(res)
return errorNotFound(res)
}
}
if (!checkViewPermission(req, note)) {
return response.errorForbidden(res)
return errorForbidden(res)
} else {
return callback(note)
}
}).catch(function (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
})
})
}
@ -213,7 +218,7 @@ function showPublishNote (req, res, next) {
}
note.increment('viewcount').then(function (note) {
if (!note) {
return response.errorNotFound(res)
return errorNotFound(res)
}
var body = note.content
var extracted = models.Note.extractMeta(body)
@ -242,7 +247,7 @@ function showPublishNote (req, res, next) {
return renderPublish(data, res)
}).catch(function (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
})
}, include)
}
@ -311,17 +316,22 @@ function actionPDF (req, res, note) {
var content = extracted.markdown
var title = models.Note.decodeTitle(note.title)
var highlightCssPath = path.join(config.appRootPath, '/node_modules/highlight.js/styles/github-gist.css')
if (!fs.existsSync(config.tmpPath)) {
fs.mkdirSync(config.tmpPath)
}
var path = config.tmpPath + '/' + Date.now() + '.pdf'
var pdfPath = config.tmpPath + '/' + Date.now() + '.pdf'
content = content.replace(/\]\(\//g, '](' + url + '/')
markdownpdf().from.string(content).to(path, function () {
if (!fs.existsSync(path)) {
logger.error('PDF seems to not be generated as expected. File doesn\'t exist: ' + path)
return response.errorInternalError(res)
var markdownpdfOptions = {
highlightCssPath: highlightCssPath
}
var stream = fs.createReadStream(path)
markdownpdf(markdownpdfOptions).from.string(content).to(pdfPath, function () {
if (!fs.existsSync(pdfPath)) {
logger.error('PDF seems to not be generated as expected. File doesn\'t exist: ' + pdfPath)
return errorInternalError(res)
}
var stream = fs.createReadStream(pdfPath)
var filename = title
// Be careful of special characters
filename = encodeURIComponent(filename)
@ -331,7 +341,7 @@ function actionPDF (req, res, note) {
res.setHeader('Content-Type', 'application/pdf; charset=UTF-8')
res.setHeader('X-Robots-Tag', 'noindex, nofollow') // prevent crawling
stream.pipe(res)
fs.unlinkSync(path)
fs.unlinkSync(pdfPath)
})
}
@ -354,10 +364,10 @@ function actionRevision (req, res, note) {
models.Revision.getPatchedNoteRevisionByTime(note, time, function (err, content) {
if (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
}
if (!content) {
return response.errorNotFound(res)
return errorNotFound(res)
}
res.set({
'Access-Control-Allow-Origin': '*', // allow CORS as API
@ -369,13 +379,13 @@ function actionRevision (req, res, note) {
res.send(content)
})
} else {
return response.errorNotFound(res)
return errorNotFound(res)
}
} else {
models.Revision.getNoteRevisions(note, function (err, data) {
if (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
}
var out = {
revision: data
@ -415,7 +425,7 @@ function noteActions (req, res, next) {
actionPDF(req, res, note)
} else {
logger.error('PDF export failed: Disabled by config. Set "allowPDFExport: true" to enable. Check the documentation for details')
response.errorForbidden(res)
errorForbidden(res)
}
break
case 'gist':
@ -480,7 +490,7 @@ function githubActionGist (req, res, note) {
var code = req.query.code
var state = req.query.state
if (!code || !state) {
return response.errorForbidden(res)
return errorForbidden(res)
} else {
var data = {
client_id: config.github.clientID,
@ -501,17 +511,17 @@ function githubActionGist (req, res, note) {
var title = models.Note.decodeTitle(note.title)
var filename = title.replace('/', ' ') + '.md'
var gist = {
'files': {}
files: {}
}
gist.files[filename] = {
'content': content
content: content
}
var gistUrl = 'https://api.github.com/gists'
request({
url: gistUrl,
headers: {
'User-Agent': 'CodiMD',
'Authorization': 'token ' + accessToken
Authorization: 'token ' + accessToken
},
method: 'POST',
json: gist
@ -520,14 +530,14 @@ function githubActionGist (req, res, note) {
res.setHeader('referer', '')
res.redirect(body.html_url)
} else {
return response.errorForbidden(res)
return errorForbidden(res)
}
})
} else {
return response.errorForbidden(res)
return errorForbidden(res)
}
} else {
return response.errorForbidden(res)
return errorForbidden(res)
}
})
}
@ -555,7 +565,7 @@ function gitlabActionProjects (req, res, note) {
id: req.user.id
}
}).then(function (user) {
if (!user) { return response.errorNotFound(res) }
if (!user) { return errorNotFound(res) }
var ret = { baseURL: config.gitlab.baseURL, version: config.gitlab.version }
ret.accesstoken = user.accessToken
ret.profileid = user.profileid
@ -572,10 +582,10 @@ function gitlabActionProjects (req, res, note) {
)
}).catch(function (err) {
logger.error('gitlab action projects failed: ' + err)
return response.errorInternalError(res)
return errorInternalError(res)
})
} else {
return response.errorForbidden(res)
return errorForbidden(res)
}
}
@ -593,7 +603,7 @@ function showPublishSlide (req, res, next) {
if ((note.alias && shortid !== note.alias) || (!note.alias && shortid !== note.shortid)) { return res.redirect(config.serverURL + '/p/' + (note.alias || note.shortid)) }
note.increment('viewcount').then(function (note) {
if (!note) {
return response.errorNotFound(res)
return errorNotFound(res)
}
var body = note.content
var extracted = models.Note.extractMeta(body)
@ -624,7 +634,7 @@ function showPublishSlide (req, res, next) {
return renderPublishSlide(data, res)
}).catch(function (err) {
logger.error(err)
return response.errorInternalError(res)
return errorInternalError(res)
})
}, include)
}
@ -635,5 +645,3 @@ function renderPublishSlide (data, res) {
})
res.render('slide.ejs', data)
}
module.exports = response

View File

@ -7,7 +7,7 @@ exports.isSQLite = function isSQLite (sequelize) {
}
exports.getImageMimeType = function getImageMimeType (imagePath) {
var fileExtension = /[^.]+$/.exec(imagePath)
const fileExtension = /[^.]+$/.exec(imagePath)
switch (fileExtension[0]) {
case 'bmp':

View File

@ -6,7 +6,7 @@ const DropboxStrategy = require('passport-dropbox-oauth2').Strategy
const config = require('../../../config')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let dropboxAuth = module.exports = Router()
const dropboxAuth = module.exports = Router()
passport.use(new DropboxStrategy({
apiVersion: '2',

View File

@ -11,7 +11,7 @@ const { setReturnToFromReferer } = require('../utils')
const { urlencodedParser } = require('../../utils')
const response = require('../../../response')
let emailAuth = module.exports = Router()
const emailAuth = module.exports = Router()
passport.use(new LocalStrategy({
usernameField: 'email'

View File

@ -7,7 +7,7 @@ const FacebookStrategy = require('passport-facebook').Strategy
const config = require('../../../config')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let facebookAuth = module.exports = Router()
const facebookAuth = module.exports = Router()
passport.use(new FacebookStrategy({
clientID: config.facebook.clientID,

View File

@ -6,13 +6,21 @@ const GithubStrategy = require('passport-github').Strategy
const config = require('../../../config')
const response = require('../../../response')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
const { URL } = require('url')
let githubAuth = module.exports = Router()
const githubAuth = module.exports = Router()
function githubUrl (path) {
return config.github.enterpriseURL && new URL(path, config.github.enterpriseURL).toString()
}
passport.use(new GithubStrategy({
clientID: config.github.clientID,
clientSecret: config.github.clientSecret,
callbackURL: config.serverURL + '/auth/github/callback'
callbackURL: config.serverURL + '/auth/github/callback',
authorizationURL: githubUrl('login/oauth/authorize'),
tokenURL: githubUrl('login/oauth/access_token'),
userProfileURL: githubUrl('api/v3/user')
}, passportGeneralCallback))
githubAuth.get('/auth/github', function (req, res, next) {

View File

@ -6,16 +6,24 @@ const GitlabStrategy = require('passport-gitlab2').Strategy
const config = require('../../../config')
const response = require('../../../response')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
const HttpsProxyAgent = require('https-proxy-agent')
let gitlabAuth = module.exports = Router()
const gitlabAuth = module.exports = Router()
passport.use(new GitlabStrategy({
const gitlabAuthStrategy = new GitlabStrategy({
baseURL: config.gitlab.baseURL,
clientID: config.gitlab.clientID,
clientSecret: config.gitlab.clientSecret,
scope: config.gitlab.scope,
callbackURL: config.serverURL + '/auth/gitlab/callback'
}, passportGeneralCallback))
}, passportGeneralCallback)
if (process.env['https_proxy']) {
const httpsProxyAgent = new HttpsProxyAgent(process.env['https_proxy'])
gitlabAuthStrategy._oauth2.setAgent(httpsProxyAgent)
}
passport.use(gitlabAuthStrategy)
gitlabAuth.get('/auth/gitlab', function (req, res, next) {
setReturnToFromReferer(req)

View File

@ -6,7 +6,7 @@ var GoogleStrategy = require('passport-google-oauth20').Strategy
const config = require('../../../config')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let googleAuth = module.exports = Router()
const googleAuth = module.exports = Router()
passport.use(new GoogleStrategy({
clientID: config.google.clientID,

View File

@ -10,7 +10,7 @@ const { setReturnToFromReferer } = require('../utils')
const { urlencodedParser } = require('../../utils')
const response = require('../../../response')
let ldapAuth = module.exports = Router()
const ldapAuth = module.exports = Router()
passport.use(new LDAPStrategy({
server: {

View File

@ -8,11 +8,11 @@ const OAuthStrategy = require('passport-oauth2').Strategy
const config = require('../../../config')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let mattermostAuth = module.exports = Router()
const mattermostAuth = module.exports = Router()
const mattermostClient = new MattermostClient()
let mattermostStrategy = new OAuthStrategy({
const mattermostStrategy = new OAuthStrategy({
authorizationURL: config.mattermost.baseURL + '/oauth/authorize',
tokenURL: config.mattermost.baseURL + '/oauth/access_token',
clientID: config.mattermost.clientID,

View File

@ -6,7 +6,7 @@ const { Strategy, InternalOAuthError } = require('passport-oauth2')
const config = require('../../../config')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let oauth2Auth = module.exports = Router()
const oauth2Auth = module.exports = Router()
class OAuth2CustomStrategy extends Strategy {
constructor (options, verify) {
@ -31,7 +31,7 @@ class OAuth2CustomStrategy extends Strategy {
return done(new Error('Failed to parse user profile'))
}
let profile = parseProfile(json)
const profile = parseProfile(json)
profile.provider = 'oauth2'
done(null, profile)
@ -76,7 +76,7 @@ OAuth2CustomStrategy.prototype.userProfile = function (accessToken, done) {
return done(new Error('Failed to parse user profile'))
}
let profile = parseProfile(json)
const profile = parseProfile(json)
profile.provider = 'oauth2'
done(null, profile)

View File

@ -9,7 +9,7 @@ const logger = require('../../../logger')
const { urlencodedParser } = require('../../utils')
const { setReturnToFromReferer } = require('../utils')
let openIDAuth = module.exports = Router()
const openIDAuth = module.exports = Router()
passport.use(new OpenIDStrategy({
returnURL: config.serverURL + '/auth/openid/callback',

View File

@ -10,7 +10,7 @@ const { urlencodedParser } = require('../../utils')
const fs = require('fs')
const intersection = function (array1, array2) { return array1.filter((n) => array2.includes(n)) }
let samlAuth = module.exports = Router()
const samlAuth = module.exports = Router()
passport.use(new SamlStrategy({
callbackUrl: config.serverURL + '/auth/saml/callback',

View File

@ -7,7 +7,7 @@ const TwitterStrategy = require('passport-twitter').Strategy
const config = require('../../../config')
const { setReturnToFromReferer, passportGeneralCallback } = require('../utils')
let twitterAuth = module.exports = Router()
const twitterAuth = module.exports = Router()
passport.use(new TwitterStrategy({
consumerKey: config.twitter.consumerKey,

View File

@ -0,0 +1,31 @@
'use strict'
const config = require('../../config')
const logger = require('../../logger')
const lutim = require('lutim')
exports.uploadImage = function (imagePath, callback) {
if (!imagePath || typeof imagePath !== 'string') {
callback(new Error('Image path is missing or wrong'), null)
return
}
if (!callback || typeof callback !== 'function') {
logger.error('Callback has to be a function')
return
}
if (config.lutim && config.lutim.url) {
lutim.setAPIUrl(config.lutim.url)
}
lutim.uploadImage(imagePath)
.then(function (json) {
if (config.debug) {
logger.info('SERVER uploadimage success: ' + JSON.stringify(json))
}
callback(null, lutim.getAPIUrl() + json.msg.short)
}).catch(function (err) {
callback(new Error(err), null)
})
}

View File

@ -32,16 +32,16 @@ exports.uploadImage = function (imagePath, callback) {
return
}
let key = path.join('uploads', path.basename(imagePath))
let protocol = config.minio.secure ? 'https' : 'http'
const key = path.join('uploads', path.basename(imagePath))
const protocol = config.minio.secure ? 'https' : 'http'
minioClient.putObject(config.s3bucket, key, buffer, buffer.size, getImageMimeType(imagePath), function (err, data) {
if (err) {
callback(new Error(err), null)
return
}
let hidePort = [80, 443].includes(config.minio.port)
let urlPort = hidePort ? '' : `:${config.minio.port}`
const hidePort = [80, 443].includes(config.minio.port)
const urlPort = hidePort ? '' : `:${config.minio.port}`
callback(null, `${protocol}://${config.minio.endPoint}${urlPort}/${config.s3bucket}/${key}`)
})
})

View File

@ -26,7 +26,7 @@ exports.uploadImage = function (imagePath, callback) {
callback(new Error(err), null)
return
}
let params = {
const params = {
Bucket: config.s3bucket,
Key: path.join('uploads', path.basename(imagePath)),
Body: buffer

View File

@ -5,7 +5,7 @@ const toobusy = require('toobusy-js')
const config = require('../../config')
const response = require('../../response')
toobusy.maxLag(config.responseMaxLag);
toobusy.maxLag(config.responseMaxLag)
module.exports = function (req, res, next) {
if (toobusy()) {

View File

@ -97,8 +97,11 @@ statusRouter.get('/config', function (req, res) {
urlpath: config.urlPath,
debug: config.debug,
version: config.fullversion,
plantumlServer: config.plantuml.server,
DROPBOX_APP_KEY: config.dropbox.appKey,
allowedUploadMimeTypes: config.allowedUploadMimeTypes
allowedUploadMimeTypes: config.allowedUploadMimeTypes,
defaultUseHardbreak: config.defaultUseHardbreak,
linkifyHeaderStyle: config.linkifyHeaderStyle
}
res.set({
'Cache-Control': 'private', // only cache by client

View File

@ -69,8 +69,7 @@ UserRouter.get('/me/delete/:token?', function (req, res) {
// export the data of the authenticated user
UserRouter.get('/me/export', function (req, res) {
if (req.isAuthenticated()) {
// let output = fs.createWriteStream(__dirname + '/example.zip');
let archive = archiver('zip', {
const archive = archiver('zip', {
zlib: { level: 3 } // Sets the compression level.
})
res.setHeader('Content-Type', 'application/zip')
@ -90,14 +89,14 @@ UserRouter.get('/me/export', function (req, res) {
ownerId: user.id
}
}).then(function (notes) {
let filenames = {}
const filenames = {}
async.each(notes, function (note, callback) {
let basename = note.title.replace(/\//g, '-') // Prevent subdirectories
const basename = note.title.replace(/\//g, '-') // Prevent subdirectories
let filename
let suffix = ''
let suffix = 0
do {
let seperator = typeof suffix === 'number' ? '-' : ''
filename = basename + seperator + suffix + '.md'
const separator = suffix === 0 ? '' : '-'
filename = basename + separator + suffix + '.md'
suffix++
} while (filenames[filename])
filenames[filename] = true

View File

@ -13,7 +13,7 @@ process.on('message', function (data) {
}
switch (data.msg) {
case 'create patch':
if (!data.hasOwnProperty('lastDoc') || !data.hasOwnProperty('currDoc')) {
if (!Object.hasOwnProperty.call(data, 'lastDoc') || !Object.hasOwnProperty.call(data, 'currDoc')) {
return logger.error('dmp worker error: not enough data on create patch')
}
try {
@ -33,7 +33,7 @@ process.on('message', function (data) {
}
break
case 'get revision':
if (!data.hasOwnProperty('revisions') || !data.hasOwnProperty('count')) {
if (!Object.hasOwnProperty.call(data, 'revisions') || !Object.hasOwnProperty.call(data, 'count')) {
return logger.error('dmp worker error: not enough data on get revision')
}
try {
@ -77,12 +77,12 @@ function getRevision (revisions, count) {
if (count <= Math.round(revisions.length / 2)) {
// start from top to target
for (let i = 0; i < count; i++) {
let revision = revisions[i]
const revision = revisions[i]
if (i === 0) {
startContent = revision.content || revision.lastContent
}
if (i !== count - 1) {
let patch = dmp.patch_fromText(revision.patch)
const patch = dmp.patch_fromText(revision.patch)
applyPatches = applyPatches.concat(patch)
}
lastPatch = revision.patch
@ -99,13 +99,13 @@ function getRevision (revisions, count) {
// start from bottom to target
var l = revisions.length - 1
for (var i = l; i >= count - 1; i--) {
let revision = revisions[i]
const revision = revisions[i]
if (i === l) {
startContent = revision.lastContent
authorship = revision.authorship
}
if (revision.patch) {
let patch = dmp.patch_fromText(revision.patch)
const patch = dmp.patch_fromText(revision.patch)
applyPatches = applyPatches.concat(patch)
}
lastPatch = revision.patch

View File

@ -1,6 +1,6 @@
{
"name": "codimd",
"version": "1.3.1",
"version": "1.4.0",
"description": "Realtime collaborative markdown notes on all platforms.",
"keywords": [
"Collaborative",
@ -15,72 +15,78 @@
"license": "AGPL-3.0",
"main": "app.js",
"scripts": {
"build": "webpack --config webpack.prod.js --progress --colors --bail",
"build": "webpack --config webpack.prod.js --display errors-only -p",
"dev": "webpack --config webpack.dev.js --progress --colors --watch",
"doctoc": "doctoc --title='# Table of Contents' README.md",
"eslint": "eslint lib public test app.js",
"postinstall": "bin/heroku",
"jsonlint": "find . -not -path './node_modules/*' -type f -name '*.json' -o -type f -name '*.json.example' | while read json; do echo $json ; jq . $json; done",
"standard": "echo 'standard is no longer being used, use `npm run eslint` instead!' && exit 1",
"lint": "standard",
"jsonlint": "find . -type f -not -ipath \"./node_modules/*\" \\( -name \"*.json\" -o -name \"*.json.*\" \\) | xargs -n 1 -I{} -- bash -c 'echo {}; jq . {} > /dev/null;'",
"start": "sequelize db:migrate && node app.js",
"test": "npm run-script eslint && npm run-script jsonlint && mocha"
"mocha": "mocha --require intelli-espower-loader --exit ./test --recursive",
"mocha:ci": "mocha --no-color -R dot --require intelli-espower-loader --exit ./test --recursive",
"coverage": "nyc mocha --require intelli-espower-loader --exit --recursive ./test",
"coverage:ci": "nyc mocha --no-color -R dot --require intelli-espower-loader --exit --recursive ./test",
"test": "npm run-script lint && npm run-script jsonlint && npm run-script coverage",
"test:ci": "npm run-script lint && npm run-script jsonlint && npm run-script coverage:ci",
"postinstall": "bin/heroku"
},
"dependencies": {
"@hackmd/codemirror": "^5.46.2",
"@hackmd/diff-match-patch": "~1.1.1",
"@hackmd/codemirror": "~5.46.2",
"@hackmd/diff-match-patch": "~1.1.3",
"@hackmd/idle-js": "~1.0.1",
"@hackmd/imgur": "~0.4.1",
"@hackmd/js-sequence-diagrams": "~0.0.1-alpha.3",
"@hackmd/lz-string": "~1.4.4",
"@hackmd/meta-marked": "~0.4.4",
"@passport-next/passport-openid": "~1.0.0",
"archiver": "~2.1.1",
"async": "~2.1.4",
"aws-sdk": "~2.345.0",
"azure-storage": "~2.10.2",
"base64url": "~3.0.0",
"body-parser": "~1.18.3",
"@susisu/mte-kernel": "^2.1.0",
"archiver": "~3.1.1",
"async": "~3.1.0",
"aws-sdk": "~2.503.0",
"azure-storage": "~2.10.3",
"babel-polyfill": "~6.26.0",
"base64url": "~3.0.1",
"body-parser": "~1.19.0",
"bootstrap": "~3.4.0",
"bootstrap-validator": "~0.11.8",
"chance": "~1.0.4",
"chance": "~1.0.18",
"cheerio": "~0.22.0",
"compression": "~1.7.4",
"connect-flash": "~0.1.1",
"connect-session-sequelize": "~6.0.0",
"cookie": "~0.3.1",
"cookie-parser": "~1.4.3",
"cookie": "~0.4.0",
"cookie-parser": "~1.4.4",
"deep-freeze": "~0.0.1",
"ejs": "~2.5.5",
"emojify.js": "~1.1.0",
"express": "~4.16.4",
"express-session": "~1.16.1",
"file-saver": "~1.3.3",
"flowchart.js": "~1.12.0",
"fork-awesome": "~1.1.3",
"ejs": "~2.6.2",
"express": "~4.17.1",
"express-session": "~1.16.2",
"file-saver": "~2.0.2",
"flowchart.js": "~1.12.2",
"fork-awesome": "~1.1.7",
"formidable": "~1.2.1",
"gist-embed": "~2.6.0",
"graceful-fs": "~4.1.11",
"handlebars": "~4.0.13",
"helmet": "~3.13.0",
"highlight.js": "~9.12.0",
"graceful-fs": "~4.2.1",
"handlebars": "~4.1.2",
"helmet": "~3.20.0",
"highlight.js": "~9.15.9",
"https-proxy-agent": "^3.0.1",
"i18n": "~0.8.3",
"ionicons": "~2.0.1",
"isomorphic-fetch": "^2.2.1",
"jquery": "~3.1.1",
"isomorphic-fetch": "~2.2.1",
"jquery": "~3.4.1",
"jquery-mousewheel": "~3.1.13",
"jquery-ui": "~1.12.1",
"js-cookie": "~2.1.3",
"js-cookie": "~2.2.0",
"js-yaml": "~3.13.1",
"jsdom-nogyp": "~0.8.3",
"keymaster": "~1.6.2",
"list.js": "~1.5.0",
"lodash": "~4.17.11",
"markdown-it": "~8.2.2",
"lodash": "~4.17.15",
"lutim": "~1.0.2",
"markdown-it": "~9.0.1",
"markdown-it-abbr": "~1.0.4",
"markdown-it-container": "~2.0.0",
"markdown-it-deflist": "~2.0.1",
"markdown-it-emoji": "~1.3.0",
"markdown-it-footnote": "~3.0.1",
"markdown-it-deflist": "~2.0.3",
"markdown-it-emoji": "~1.4.0",
"markdown-it-footnote": "~3.0.2",
"markdown-it-imsize": "~2.0.1",
"markdown-it-ins": "~2.0.0",
"markdown-it-mark": "~2.0.0",
@ -89,15 +95,16 @@
"markdown-it-sub": "~1.0.0",
"markdown-it-sup": "~1.0.0",
"markdown-pdf": "~9.0.0",
"mathjax": "~2.7.0",
"mattermost-redux": "^5.9.0",
"mermaid": "~7.1.0",
"method-override": "~2.3.7",
"markdownlint": "^0.17.0",
"mathjax": "~2.7.5",
"mattermost-redux": "~5.13.0",
"mermaid": "~8.2.3",
"method-override": "~3.0.0",
"minimist": "~1.2.0",
"minio": "~6.0.0",
"minio": "~7.0.10",
"moment": "~2.24.0",
"morgan": "~1.9.1",
"mysql": "~2.16.0",
"mysql": "~2.17.1",
"passport": "~0.4.0",
"passport-dropbox-oauth2": "~1.1.0",
"passport-facebook": "~2.1.1",
@ -110,11 +117,12 @@
"passport-saml": "~1.0.0",
"passport-twitter": "~1.0.4",
"passport.socketio": "~3.7.0",
"pdfobject": "~2.0.201604172",
"pdfobject": "~2.1.1",
"pg": "~6.1.2",
"pg-hstore": "~2.3.2",
"prismjs": "~1.6.0",
"randomcolor": "~0.5.3",
"plantuml-encoder": "^1.2.5",
"prismjs": "~1.17.1",
"randomcolor": "~0.5.4",
"raphael": "~2.2.8",
"readline-sync": "~1.4.7",
"request": "~2.88.0",
@ -122,64 +130,67 @@
"scrypt": "~6.0.3",
"select2": "~3.5.2-browserify",
"sequelize": "5.3.5",
"shortid": "~2.2.8",
"socket.io": "~2.1.1",
"socket.io-client": "~2.1.1",
"spin.js": "~2.3.2",
"sqlite3": "~4.0.1",
"sequelize-cli": "~5.4.0",
"shortid": "~2.2.14",
"socket.io": "~2.2.0",
"socket.io-client": "~2.2.0",
"spin.js": "~4.0.0",
"sqlite3": "~4.0.9",
"store": "~2.0.12",
"tedious": "~6.1.0",
"tedious": "~6.2.0",
"toobusy-js": "~0.5.1",
"turndown": "~5.0.1",
"uuid": "~3.1.0",
"validator": "~10.4.0",
"velocity-animate": "~1.4.0",
"visibilityjs": "~1.2.4",
"viz.js": "~1.7.0",
"winston": "~3.1.0",
"ws": "~6.0.0",
"turndown": "~5.0.3",
"uuid": "~3.3.2",
"validator": "~11.1.0",
"vega": "~5.4.0",
"vega-embed": "~4.2.2",
"vega-lite": "~3.4.0",
"velocity-animate": "~1.5.2",
"visibilityjs": "~2.0.2",
"viz.js": "~2.1.2",
"winston": "~3.2.1",
"ws": "~7.1.1",
"wurl": "~2.5.3",
"xss": "~1.0.3"
"xss": "~1.0.6"
},
"devDependencies": {
"@hackmd/emojify.js": "^2.1.0",
"acorn": "~6.1.1",
"babel-core": "~6.26.3",
"babel-loader": "~7.1.4",
"babel-plugin-transform-runtime": "~6.23.0",
"babel-polyfill": "~6.26.0",
"babel-preset-env": "~1.7.0",
"babel-runtime": "~6.26.0",
"copy-webpack-plugin": "~4.5.2",
"css-loader": "~1.0.0",
"doctoc": "~1.4.0",
"ejs-loader": "~0.3.1",
"eslint": "~5.16.0",
"eslint-config-standard": "~12.0.0",
"eslint-plugin-import": "~2.17.1",
"eslint-plugin-node": "~8.0.1",
"eslint-plugin-promise": "~4.1.1",
"eslint-plugin-standard": "~4.0.0",
"exports-loader": "~0.7.0",
"expose-loader": "~0.7.5",
"file-loader": "~2.0.0",
"html-webpack-plugin": "~4.0.0-beta.2",
"imports-loader": "~0.8.0",
"intelli-espower-loader": "~1.0.1",
"jsonlint": "~1.6.2",
"less": "~3.9.0",
"less-loader": "~4.1.0",
"markdown-it-ruby": "^0.1.1",
"mini-css-extract-plugin": "~0.4.1",
"mocha": "~5.2.0",
"mock-require": "~3.0.3",
"nyc": "~14.0.0",
"optimize-css-assets-webpack-plugin": "~5.0.0",
"power-assert": "~1.6.1",
"script-loader": "~0.7.2",
"sequelize-cli": "~5.4.0",
"sinon": "~7.3.2",
"standard": "~13.1.0",
"string-loader": "~0.0.1",
"style-loader": "~0.21.0",
"style-loader": "~0.23.1",
"uglifyjs-webpack-plugin": "~1.2.7",
"url-loader": "~1.0.1",
"webpack": "~4.30.0",
"webpack-cli": "~3.3.0",
"webpack-merge": "~4.1.4",
"webpack": "~4.39.0",
"webpack-cli": "~3.3.6",
"webpack-merge": "~4.2.1",
"webpack-parallel-uglify-plugin": "~1.1.0"
},
"optionalDependencies": {
@ -187,7 +198,7 @@
"utf-8-validate": "~5.0.1"
},
"engines": {
"node": ">=8.0.0"
"node": ">=8.0.0 <12.0.0"
},
"maintainers": [
{
@ -198,5 +209,21 @@
"name": "Christoph (Sheogorath) Kern",
"email": "codimd@sheogorath.shivering-isles.com"
}
],
"standard": {
"ignore": [
"/public/build",
"/public/vendor",
"/lib/ot",
"webpack.*"
]
},
"nyc": {
"all": true,
"include": [
"app.js",
"lib/**/*.js"
],
"reporter": "lcov"
}
}

View File

@ -10,7 +10,6 @@ module.exports = {
"moment": false,
"editor": false,
"ui": false,
"Spinner": false,
"modeType": false,
"serverurl": false,
"key": false,

View File

@ -25,6 +25,32 @@ body.night{
border: 1px solid #343434;
}
.toolbar > .btn-toolbar {
white-space: nowrap;
overflow-y: auto;
scrollbar-width: none;
}
.toolbar > .btn-toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > .btn-toolbar > .btn-group {
float: none;
}
.toolbar > .btn-toolbar > .btn-group > span {
display: inline-block;
float: left;
color: #fff;
padding: 5px;
line-height: 22px;
}
.toolbar > .btn-toolbar > .btn-group > span.separator {
color: #4d4d4d;
}
.toolbar > .btn-toolbar > .btn-group > .btn {
background-color: #1c1c1e;
padding: 5px;
@ -513,6 +539,7 @@ div[contenteditable]:empty:not(:focus):before{
.status-bar .status-indicators .status-keymap > a,
.status-bar .status-indicators .status-theme > a,
.status-bar .status-indicators .status-spellcheck > a,
.status-bar .status-indicators .status-linter > a,
.status-bar .status-indicators .status-preferences > a {
color: inherit;
text-decoration: none;
@ -520,6 +547,7 @@ div[contenteditable]:empty:not(:focus):before{
.status-bar .status-indicators .status-theme,
.status-bar .status-indicators .status-spellcheck,
.status-bar .status-indicators .status-linter,
.status-bar .status-indicators .status-preferences {
padding: 0 4.3px;
}
@ -540,17 +568,20 @@ div[contenteditable]:empty:not(:focus):before{
}
.ui-theme-toggle,
.ui-linter-toggle,
.ui-spellcheck-toggle {
opacity: 0.2;
cursor: pointer;
}
.ui-theme-toggle.active,
.ui-linter-toggle.active,
.ui-spellcheck-toggle.active {
opacity: 1;
}
.ui-theme-toggle:hover,
.ui-linter-toggle:hover,
.ui-spellcheck-toggle:hover {
opacity: 0.8;
}

View File

@ -126,7 +126,8 @@
.markdown-body pre.sequence-diagram,
.markdown-body pre.graphviz,
.markdown-body pre.mermaid,
.markdown-body pre.abc {
.markdown-body pre.abc,
.markdown-body pre.vega {
text-align: center;
background-color: inherit;
border-radius: 0;
@ -147,7 +148,8 @@
.markdown-body pre.sequence-diagram > code,
.markdown-body pre.graphviz > code,
.markdown-body pre.mermaid > code,
.markdown-body pre.abc > code {
.markdown-body pre.abc > code,
.markdown-body pre.vega > code {
text-align: left;
}
@ -155,7 +157,8 @@
.markdown-body pre.sequence-diagram > svg,
.markdown-body pre.graphviz > svg,
.markdown-body pre.mermaid > svg,
.markdown-body pre.abc > svg {
.markdown-body pre.abc > svg,
.markdown-body pre.vega > svg {
max-width: 100%;
height: 100%;
}

View File

@ -223,7 +223,8 @@ pre.flow-chart,
pre.sequence-diagram,
pre.graphviz,
pre.mermaid,
pre.abc {
pre.abc,
pre.vega {
text-align: center;
background-color: white;
border-radius: 0;
@ -234,7 +235,8 @@ pre.flow-chart > code,
pre.sequence-diagram > code,
pre.graphviz > code,
pre.mermaid > code,
pre.abc > code {
pre.abc > code,
pre.vega > code {
text-align: left;
}
@ -242,7 +244,8 @@ pre.flow-chart > svg,
pre.sequence-diagram > svg,
pre.graphviz > svg,
pre.mermaid > svg,
pre.abc > svg {
pre.abc > svg,
pre.vega > svg {
max-width: 100%;
height: 100%;
}
@ -255,19 +258,10 @@ pre.abc > svg {
margin-bottom: -.25em !important;
}
.reveal .slides, .reveal .backgrounds, .reveal.overview {
transform-style: preserve-3d;
}
.slides, #meta {
display: none;
}
.reveal .slides > section,
.reveal .slides > section > section {
transform-style: flat;
}
.reveal.rtl .slides,
.reveal.rtl .slides h1,
.reveal.rtl .slides h2,

View File

@ -313,11 +313,45 @@ GABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|
g2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|
```
### PlantUML
```plantuml
start
if (condition A) then (yes)
:Text 1;
elseif (condition B) then (yes)
:Text 2;
stop
elseif (condition C) then (yes)
:Text 3;
elseif (condition D) then (yes)
:Text 4;
else (nothing)
:Text else;
endif
stop
```
### Vega-Lite
```vega
{
"$schema": "https://vega.github.io/schema/vega-lite/v3.json",
"data": {"url": "https://vega.github.io/editor/data/barley.json"},
"mark": "bar",
"encoding": {
"x": {"aggregate": "sum", "field": "yield", "type": "quantitative"},
"y": {"field": "variety", "type": "nominal"},
"color": {"field": "site", "type": "nominal"}
}
}
```
> More information about **sequence diagrams** syntax [here](http://bramp.github.io/js-sequence-diagrams/).
> More information about **flow charts** syntax [here](http://adrai.github.io/flowchart.js/).
> More information about **graphviz** syntax [here](http://www.tonyballantyne.com/graphs.html)
> More information about **mermaid** syntax [here](http://knsv.github.io/mermaid)
> More information about **abc** syntax [here](http://abcnotation.com/learn)
> More information about **plantuml** syntax [here](http://plantuml.com/index)
> More information about **vega** syntax [here](https://vega.github.io/vega-lite/docs)
Alert Area
---
@ -337,6 +371,10 @@ Watch out :zap:
Oh No! :fire:
:::
:::spoiler Click to show details
You found me :stuck_out_tongue_winking_eye:
:::
## Typography
### Headers
@ -397,6 +435,7 @@ Subscript: H~2~O
==Marked text==
{ruby base|rubytext}
### Blockquotes

View File

@ -1,6 +1,36 @@
Release Notes
===
<i class="fa fa-tag"></i> 1.4.0 Syrmaticus mikado <i class="fa fa-clock-o"></i> 2019-11-11
---
<div style="text-align: center; margin-bottom: 1em;">
<img src="https://upload.wikimedia.org/wikipedia/commons/6/60/Mikado_Pheasant_398.jpg" width="300">
<small style="display: block;">Mikado Pheasant, photo credits to <a href="https://zh.wikipedia.org/wiki/File:Mikado_Pheasant_398.jpg">Snowyowls from wikipedia</a></small>
</div>
Starting from version 1.4.0, we'll pick one species from [_the endemic species of Taiwan_](https://en.wikipedia.org/wiki/List_of_endemic_species_of_Taiwan) as version name. Is there anyone still remember we've once used type of coffee as our version name? It's time to revive that good convention, but this time we don't need coffee to stay up all night. 💤
It has been over 200+ commits since our last release. These are the highlights from version 1.4.0:
- [New table tools][table-tools] - Create table with auto-formatting and keyboard shortcut
- [Markdownlint integration][markdownlint] - Lint you markdown document
- [Support PlantUML, vega-lite renderer][more-renderers] - More renderers to come
- [Support spoiler container, ruby markdown syntax][more-syntax]
- [New Emoji sets][new-emoji]
- [Slide mode plugins][slide-mode-plugins]: Elapsed time bar and Spotlight
[Go read the complete release note here][v1_4_0]. Thank you CodiMD community and all our contributors. ❤️
[table-tools]: https://hackmd.io/@codimd/v1_4_0#New-Table-Tools
[markdownlint]: https://hackmd.io/@codimd/v1_4_0#Markdownlint-integration
[more-renderers]: https://hackmd.io/@codimd/v1_4_0#Support-2-new-render-engines-PlantUML-and-Vega-lite
[more-syntax]: https://hackmd.io/@codimd/v1_4_0#Suppport-2-New-markdown-syntax-Spoiler-and-Ruby
[new-emoji]: https://hackmd.io/@codimd/v1_4_0#New-emoji-sets
[slide-mode-plugins]: https://hackmd.io/@codimd/v1_4_0#Slide-mode-enhancement
[v1_4_0]: https://hackmd.io/@codimd/release-notes/%2F%40codimd%2Fv1_4_0
<i class="fa fa-tag"></i> 1.3.1 <i class="fa fa-clock-o"></i> 2019-03-23 00:00
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

View File

@ -1,5 +1,5 @@
/* eslint-env browser, jquery */
/* global moment, serverurl */
/* global moment, serverurl, plantumlServer */
import Prism from 'prismjs'
import hljs from 'highlight.js'
@ -30,7 +30,9 @@ require('prismjs/components/prism-gherkin')
require('./lib/common/login')
require('../vendor/md-toc')
var Viz = require('viz.js')
const viz = new window.Viz()
const plantumlEncoder = require('plantuml-encoder')
const ui = getUIElements()
// auto update last change
@ -166,11 +168,11 @@ export function renderTags (view) {
}
function slugifyWithUTF8 (text) {
// remove html tags and trim spaces
// remove HTML tags and trim spaces
let newText = stripTags(text.toString().trim())
// replace all spaces in between to dashes
// replace space between words with dashes
newText = newText.replace(/\s+/g, '-')
// slugify string to make it valid for attribute
// slugify string to make it valid as an attribute
newText = newText.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '')
return newText
}
@ -193,7 +195,7 @@ export function isValidURL (str) {
export function parseMeta (md, edit, view, toc, tocAffix) {
let lang = null
let dir = null
let breaks = true
let breaks = window.defaultUseHardbreak
if (md && md.meta) {
const meta = md.meta
lang = meta.lang
@ -223,10 +225,10 @@ export function parseMeta (md, edit, view, toc, tocAffix) {
tocAffix.removeAttr('dir')
}
// breaks
if (typeof breaks === 'boolean' && !breaks) {
md.options.breaks = false
if (typeof breaks === 'boolean') {
md.options.breaks = breaks
} else {
md.options.breaks = true
md.options.breaks = window.defaultUseHardbreak
}
}
@ -348,7 +350,7 @@ export function finishView (view) {
$value.html('')
chart.drawSVG(value, {
'line-width': 2,
'fill': 'none',
fill: 'none',
'font-size': '16px',
'font-family': "'Andale Mono', monospace"
})
@ -367,13 +369,15 @@ export function finishView (view) {
try {
var $value = $(value)
var $ele = $(value).parent().parent()
var graphviz = Viz($value.text())
$value.unwrap()
viz.renderString($value.text())
.then(graphviz => {
if (!graphviz) throw Error('viz.js output empty graph')
$value.html(graphviz)
$ele.addClass('graphviz')
$value.children().unwrap().unwrap()
$value.children().unwrap()
})
} catch (err) {
$value.unwrap()
$value.parent().append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
@ -387,19 +391,14 @@ export function finishView (view) {
var $value = $(value)
const $ele = $(value).closest('pre')
window.mermaid.mermaidAPI.parse($value.text())
window.mermaid.parse($value.text())
$ele.addClass('mermaid')
$ele.html($value.text())
window.mermaid.init(undefined, $ele)
} catch (err) {
var errormessage = err
if (err.str) {
errormessage = err.str
}
$value.unwrap()
$value.parent().append(`<div class="alert alert-warning">${escapeHTML(errormessage)}</div>`)
console.warn(errormessage)
$value.parent().append(`<div class="alert alert-warning">${escapeHTML(err.str)}</div>`)
console.warn(err)
}
})
// abc.js
@ -422,6 +421,32 @@ export function finishView (view) {
console.warn(err)
}
})
// vega-lite
const vegas = view.find('div.vega.raw').removeClass('raw')
vegas.each((key, value) => {
try {
var $value = $(value)
var $ele = $(value).parent().parent()
const specText = $value.text()
$value.unwrap()
window.vegaEmbed($ele[0], JSON.parse(specText))
.then(result => {
$ele.addClass('vega')
})
.catch(err => {
$ele.append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
console.warn(err)
})
.finally(() => {
if (window.viewAjaxCallback) window.viewAjaxCallback()
})
} catch (err) {
$ele.append(`<div class="alert alert-warning">${escapeHTML(err)}</div>`)
console.warn(err)
}
})
// image href new window(emoji not included)
const images = view.find('img.raw[src]').removeClass('raw')
images.each((key, value) => {
@ -542,6 +567,12 @@ export function finishView (view) {
} catch (err) {
console.warn(err)
}
// register details toggle for scrollmap recalulation
view.find('details.raw').removeClass('raw').each(function (key, val) {
$(val).on('toggle', window.viewAjaxCallback)
})
// render title
document.title = renderTitle(view)
}
@ -744,12 +775,12 @@ export function generateToc (id) {
target.html('')
/* eslint-disable no-unused-vars */
var toc = new window.Toc('doc', {
'level': 3,
'top': -1,
'class': 'toc',
'ulClass': 'nav',
'targetId': id,
'process': getHeaderContent
level: 3,
top: -1,
class: 'toc',
ulClass: 'nav',
targetId: id,
process: getHeaderContent
})
/* eslint-enable no-unused-vars */
if (target.text() === 'undefined') { target.html('') }
@ -828,16 +859,44 @@ const anchorForId = id => {
return anchor
}
const createHeaderId = (headerContent, headerIds = null) => {
// to escape characters not allow in css and humanize
const slug = slugifyWithUTF8(headerContent)
let id
if (window.linkifyHeaderStyle === 'keep-case') {
id = slug
} else if (window.linkifyHeaderStyle === 'lower-case') {
// to make compatible with GitHub, GitLab, Pandoc and many more
id = slug.toLowerCase()
} else if (window.linkifyHeaderStyle === 'gfm') {
// see GitHub implementation reference:
// https://gist.github.com/asabaylus/3071099#gistcomment-1593627
// it works like 'lower-case', but ...
const idBase = slug.toLowerCase()
id = idBase
if (headerIds !== null) {
// ... making sure the id is unique
let i = 1
while (headerIds.has(id)) {
id = idBase + '-' + i
i++
}
headerIds.add(id)
}
} else {
throw new Error('Unknown linkifyHeaderStyle value "' + window.linkifyHeaderStyle + '"')
}
return id
}
const linkifyAnchors = (level, containingElement) => {
const headers = containingElement.getElementsByTagName(`h${level}`)
for (let i = 0, l = headers.length; i < l; i++) {
let header = headers[i]
const header = headers[i]
if (header.getElementsByClassName('anchor').length === 0) {
if (typeof header.id === 'undefined' || header.id === '') {
// to escape characters not allow in css and humanize
const id = slugifyWithUTF8(getHeaderContent(header))
header.id = id
header.id = createHeaderId(getHeaderContent(header))
}
if (!(typeof header.id === 'undefined' || header.id === '')) {
header.insertBefore(anchorForId(header.id), header.firstChild)
@ -863,8 +922,33 @@ function getHeaderContent (header) {
return headerHTML[0].innerHTML
}
function changeHeaderId ($header, id, newId) {
$header.attr('id', newId)
const $headerLink = $header.find(`> a.anchor[href="#${id}"]`)
$headerLink.attr('href', `#${newId}`)
$headerLink.attr('title', newId)
}
export function deduplicatedHeaderId (view) {
// headers contained in the last change
const headers = view.find(':header.raw').removeClass('raw').toArray()
if (headers.length === 0) {
return
}
if (window.linkifyHeaderStyle === 'gfm') {
// consistent with GitHub, GitLab, Pandoc & co.
// all headers contained in the document, in order of appearance
const allHeaders = view.find(`:header`).toArray()
// list of finaly assigned header IDs
const headerIds = new Set()
for (let j = 0; j < allHeaders.length; j++) {
const $header = $(allHeaders[j])
const id = $header.attr('id')
const newId = createHeaderId(getHeaderContent($header), headerIds)
changeHeaderId($header, id, newId)
}
} else {
// the legacy way
for (let i = 0; i < headers.length; i++) {
const id = $(headers[i]).attr('id')
if (!id) continue
@ -872,11 +956,9 @@ export function deduplicatedHeaderId (view) {
for (let j = 0; j < duplicatedHeaders.length; j++) {
if (duplicatedHeaders[j] !== headers[i]) {
const newId = id + j
const $duplicatedHeader = $(duplicatedHeaders[j])
$duplicatedHeader.attr('id', newId)
const $headerLink = $duplicatedHeader.find(`> a.anchor[href="#${id}"]`)
$headerLink.attr('href', `#${newId}`)
$headerLink.attr('title', newId)
const $header = $(duplicatedHeaders[j])
changeHeaderId($header, id, newId)
}
}
}
}
@ -891,12 +973,12 @@ export function renderTOC (view) {
const target = $(`#${id}`)
target.html('')
/* eslint-disable no-unused-vars */
let TOC = new window.Toc('doc', {
'level': 3,
'top': -1,
'class': 'toc',
'targetId': id,
'process': getHeaderContent
const TOC = new window.Toc('doc', {
level: 3,
top: -1,
class: 'toc',
targetId: id,
process: getHeaderContent
})
/* eslint-enable no-unused-vars */
if (target.text() === 'undefined') { target.html('') }
@ -923,6 +1005,8 @@ function highlightRender (code, lang) {
return `<div class="mermaid raw">${code}</div>`
} else if (lang === 'abc') {
return `<div class="abc raw">${code}</div>`
} else if (lang === 'vega') {
return `<div class="vega raw">${code}</div>`
}
const result = {
value: code
@ -944,9 +1028,9 @@ function highlightRender (code, lang) {
return result.value
}
export let md = markdownit('default', {
export const md = markdownit('default', {
html: true,
breaks: true,
breaks: window.defaultUseHardbreak,
langPrefix: '',
linkify: true,
typographer: true,
@ -970,6 +1054,7 @@ md.use(require('markdown-it-mathjax')({
afterDisplayMath: '\\]</span>'
}))
md.use(require('markdown-it-imsize'))
md.use(require('markdown-it-ruby'))
md.use(require('markdown-it-emoji'), {
shortcuts: {}
@ -996,8 +1081,29 @@ md.use(markdownitContainer, 'success', { render: renderContainer })
md.use(markdownitContainer, 'info', { render: renderContainer })
md.use(markdownitContainer, 'warning', { render: renderContainer })
md.use(markdownitContainer, 'danger', { render: renderContainer })
md.use(markdownitContainer, 'spoiler', {
validate: function (params) {
return params.trim().match(/^spoiler(\s+.*)?$/)
},
render: function (tokens, idx) {
const m = tokens[idx].info.trim().match(/^spoiler(\s+.*)?$/)
let defaultImageRender = md.renderer.rules.image
if (tokens[idx].nesting === 1) {
// opening tag
const summary = m[1] && m[1].trim()
if (summary) {
return `<details><summary>${md.utils.escapeHtml(summary)}</summary>\n`
} else {
return `<details>\n`
}
} else {
// closing tag
return '</details>\n'
}
}
})
const defaultImageRender = md.renderer.rules.image
md.renderer.rules.image = function (tokens, idx, options, env, self) {
tokens[idx].attrJoin('class', 'raw')
return defaultImageRender(...arguments)
@ -1041,6 +1147,33 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return `<pre><code${self.renderAttrs(token)}>${highlighted}</code></pre>\n`
}
const makePlantumlURL = (umlCode) => {
const format = 'svg'
const code = plantumlEncoder.encode(umlCode)
return `${plantumlServer}/${format}/${code}`
}
// https://github.com/qjebbs/vscode-plantuml/tree/master/src/markdown-it-plantuml
md.renderer.rules.plantuml = (tokens, idx) => {
const token = tokens[idx]
if (token.type !== 'plantuml') {
return tokens[idx].content
}
const url = makePlantumlURL(token.content)
return `<img src="${url}" />`
}
// https://github.com/qjebbs/vscode-plantuml/tree/master/src/markdown-it-plantuml
md.core.ruler.push('plantuml', (state) => {
const blockTokens = state.tokens
for (const blockToken of blockTokens) {
if (blockToken.type === 'fence' && blockToken.info === 'plantuml') {
blockToken.type = 'plantuml'
}
}
})
// youtube
const youtubePlugin = new Plugin(
// regexp to match
@ -1065,7 +1198,7 @@ const vimeoPlugin = new Plugin(
/{%vimeo\s*([\d\D]*?)\s*%}/,
(match, utils) => {
const videoid = match[1]
const videoid = match[1].split(/[?&=]+/)[0]
if (!videoid) return
const div = $('<div class="vimeo raw"></div>')
div.attr('data-videoid', videoid)
@ -1080,7 +1213,7 @@ const gistPlugin = new Plugin(
/{%gist\s*([\d\D]*?)\s*%}/,
(match, utils) => {
const gistid = match[1]
const gistid = match[1].split(/[?&=]+/)[0]
const code = `<code data-gist-id="${gistid}"></code>`
return code
}
@ -1098,7 +1231,7 @@ const slidesharePlugin = new Plugin(
/{%slideshare\s*([\d\D]*?)\s*%}/,
(match, utils) => {
const slideshareid = match[1]
const slideshareid = match[1].split(/[?&=]+/)[0]
const div = $('<div class="slideshare raw"></div>')
div.attr('data-slideshareid', slideshareid)
return div[0].outerHTML

View File

@ -144,7 +144,7 @@ export function writeHistory (title, tags) {
}
function writeHistoryToStorage (title, tags) {
let data = store.get('notehistory')
const data = store.get('notehistory')
let notehistory
if (data && typeof data === 'string') {
notehistory = JSON.parse(data)
@ -260,7 +260,7 @@ function parseToHistory (list, notehistory, callback) {
for (let i = 0; i < notehistory.length; i++) {
// migrate LZString encoded id to base64url encoded id
try {
let id = LZString.decompressFromBase64(notehistory[i].id)
const id = LZString.decompressFromBase64(notehistory[i].id)
if (id && checkNoteIdValid(id)) {
notehistory[i].id = encodeNoteId(id)
}

View File

@ -1,6 +1,6 @@
/* eslint-env browser, jquery */
/* global CodeMirror, Cookies, moment, Spinner, serverurl,
key, Dropbox, ot, hex2rgb, Visibility */
/* global CodeMirror, Cookies, moment, serverurl,
key, Dropbox, ot, hex2rgb, Visibility, inlineAttachment */
import TurndownService from 'turndown'
@ -17,6 +17,8 @@ import List from 'list.js'
import Idle from '@hackmd/idle-js'
import { Spinner } from 'spin.js'
import {
checkLoginStateChanged,
setloginStateChangeEvent
@ -83,6 +85,7 @@ require('../css/index.css')
require('../css/extra.css')
require('../css/slide-preview.css')
require('../css/site.css')
require('spin.js/spin.css')
require('highlight.js/styles/github-gist.css')
@ -95,9 +98,9 @@ var updateViewDebounce = 100
var cursorMenuThrottle = 50
var cursorActivityDebounce = 50
var cursorAnimatePeriod = 100
var supportContainers = ['success', 'info', 'warning', 'danger']
var supportContainers = ['success', 'info', 'warning', 'danger', 'spoiler']
var supportCodeModes = ['javascript', 'typescript', 'jsx', 'htmlmixed', 'htmlembedded', 'css', 'xml', 'clike', 'clojure', 'ruby', 'python', 'shell', 'php', 'sql', 'haskell', 'coffeescript', 'yaml', 'pug', 'lua', 'cmake', 'nginx', 'perl', 'sass', 'r', 'dockerfile', 'tiddlywiki', 'mediawiki', 'go', 'gherkin'].concat(hljs.listLanguages())
var supportCharts = ['sequence', 'flow', 'graphviz', 'mermaid', 'abc']
var supportCharts = ['sequence', 'flow', 'graphviz', 'mermaid', 'abc', 'plantuml', 'vega']
var supportHeaders = [
{
text: '# h1',
@ -260,7 +263,7 @@ let visibleMD = false
let visibleLG = false
const isTouchDevice = 'ontouchstart' in document.documentElement
let currentStatus = statusType.offline
let lastInfo = {
const lastInfo = {
needRestore: false,
cursor: null,
scroll: null,
@ -286,14 +289,14 @@ let lastInfo = {
let personalInfo = {}
let onlineUsers = []
const fileTypes = {
'pl': 'perl',
'cgi': 'perl',
'js': 'javascript',
'php': 'php',
'sh': 'bash',
'rb': 'ruby',
'html': 'html',
'py': 'python'
pl: 'perl',
cgi: 'perl',
js: 'javascript',
php: 'php',
sh: 'bash',
rb: 'ruby',
html: 'html',
py: 'python'
}
// editor settings
@ -334,9 +337,7 @@ var opts = {
left: '50%' // Left position relative to parent
}
/* eslint-disable no-unused-vars */
var spinner = new Spinner(opts).spin(ui.spinner[0])
/* eslint-enable no-unused-vars */
new Spinner(opts).spin(ui.spinner[0])
// idle
var idle = new Idle({
@ -955,8 +956,8 @@ ui.toolbar.export.dropbox.click(function () {
var options = {
files: [
{
'url': noteurl + '/download',
'filename': filename
url: noteurl + '/download',
filename: filename
}
],
error: function (errorMessage) {
@ -1787,29 +1788,29 @@ var authorMarks = {} // temp variable
var addTextMarkers = [] // temp variable
function updateInfo (data) {
// console.log(data);
if (data.hasOwnProperty('createtime') && window.createtime !== data.createtime) {
if (Object.hasOwnProperty.call(data, 'createtime') && window.createtime !== data.createtime) {
window.createtime = data.createtime
updateLastChange()
}
if (data.hasOwnProperty('updatetime') && window.lastchangetime !== data.updatetime) {
if (Object.hasOwnProperty.call(data, 'updatetime') && window.lastchangetime !== data.updatetime) {
window.lastchangetime = data.updatetime
updateLastChange()
}
if (data.hasOwnProperty('owner') && window.owner !== data.owner) {
if (Object.hasOwnProperty.call(data, 'owner') && window.owner !== data.owner) {
window.owner = data.owner
window.ownerprofile = data.ownerprofile
updateOwner()
}
if (data.hasOwnProperty('lastchangeuser') && window.lastchangeuser !== data.lastchangeuser) {
if (Object.hasOwnProperty.call(data, 'lastchangeuser') && window.lastchangeuser !== data.lastchangeuser) {
window.lastchangeuser = data.lastchangeuser
window.lastchangeuserprofile = data.lastchangeuserprofile
updateLastChangeUser()
updateOwner()
}
if (data.hasOwnProperty('authors') && authors !== data.authors) {
if (Object.hasOwnProperty.call(data, 'authors') && authors !== data.authors) {
authors = data.authors
}
if (data.hasOwnProperty('authorship') && authorship !== data.authorship) {
if (Object.hasOwnProperty.call(data, 'authorship') && authorship !== data.authorship) {
authorship = data.authorship
updateAuthorship()
}
@ -1854,7 +1855,7 @@ function updateAuthorshipInner () {
authorMarks = {}
for (let i = 0; i < authorship.length; i++) {
var atom = authorship[i]
let author = authors[atom[0]]
const author = authors[atom[0]]
if (author) {
var prePos = editor.posFromIndex(atom[1])
var preLine = editor.getLine(prePos.line)
@ -1872,7 +1873,7 @@ function updateAuthorshipInner () {
if (prePos.ch === preLine.length) {
startLine++
} else if (prePos.ch !== 0) {
let mark = initMarkAndCheckGutter(authorMarks[prePos.line], author, atom[3])
const mark = initMarkAndCheckGutter(authorMarks[prePos.line], author, atom[3])
var _postPos = {
line: prePos.line,
ch: preLine.length
@ -1889,7 +1890,7 @@ function updateAuthorshipInner () {
if (postPos.ch === 0) {
endLine--
} else if (postPos.ch !== postLine.length) {
let mark = initMarkAndCheckGutter(authorMarks[postPos.line], author, atom[3])
const mark = initMarkAndCheckGutter(authorMarks[postPos.line], author, atom[3])
var _prePos = {
line: postPos.line,
ch: 0
@ -1909,7 +1910,7 @@ function updateAuthorshipInner () {
}
}
} else {
let mark = initMarkAndCheckGutter(authorMarks[prePos.line], author, atom[3])
const mark = initMarkAndCheckGutter(authorMarks[prePos.line], author, atom[3])
if (JSON.stringify(prePos) !== JSON.stringify(postPos)) {
mark.textmarkers.push({
userid: author.userid,
@ -1922,15 +1923,15 @@ function updateAuthorshipInner () {
}
addTextMarkers = []
editor.eachLine(iterateLine)
var allTextMarks = editor.getAllMarks()
const allTextMarks = editor.getAllMarks()
for (let i = 0; i < allTextMarks.length; i++) {
let _textMarker = allTextMarks[i]
var pos = _textMarker.find()
var found = false
const _textMarker = allTextMarks[i]
const pos = _textMarker.find()
let found = false
for (let j = 0; j < addTextMarkers.length; j++) {
let textMarker = addTextMarkers[j]
let author = authors[textMarker.userid]
let className = 'authorship-inline-' + author.color.substr(1)
const textMarker = addTextMarkers[j]
const author = authors[textMarker.userid]
const className = 'authorship-inline-' + author.color.substr(1)
var obj = {
from: textMarker.pos[0],
to: textMarker.pos[1]
@ -1948,12 +1949,12 @@ function updateAuthorshipInner () {
}
}
for (let i = 0; i < addTextMarkers.length; i++) {
let textMarker = addTextMarkers[i]
let author = authors[textMarker.userid]
const textMarker = addTextMarkers[i]
const author = authors[textMarker.userid]
const rgbcolor = hex2rgb(author.color)
const colorString = `rgba(${rgbcolor.red},${rgbcolor.green},${rgbcolor.blue},0.7)`
const styleString = `background-image: linear-gradient(to top, ${colorString} 1px, transparent 1px);`
let className = `authorship-inline-${author.color.substr(1)}`
const className = `authorship-inline-${author.color.substr(1)}`
const rule = `.${className} { ${styleString} }`
addStyleRule(rule)
editor.markText(textMarker.pos[0], textMarker.pos[1], {
@ -1963,11 +1964,11 @@ function updateAuthorshipInner () {
}
}
function iterateLine (line) {
var lineNumber = line.lineNo()
var currMark = authorMarks[lineNumber]
var author = currMark ? authors[currMark.gutter.userid] : null
const lineNumber = line.lineNo()
const currMark = authorMarks[lineNumber]
const author = currMark ? authors[currMark.gutter.userid] : null
if (currMark && author) {
let className = 'authorship-gutter-' + author.color.substr(1)
const className = 'authorship-gutter-' + author.color.substr(1)
const gutters = line.gutterMarkers
if (!gutters || !gutters['authorship-gutters'] ||
!gutters['authorship-gutters'].className ||
@ -1975,7 +1976,7 @@ function iterateLine (line) {
const styleString = `border-left: 3px solid ${author.color}; height: ${defaultTextHeight}px; margin-left: 3px;`
const rule = `.${className} { ${styleString} }`
addStyleRule(rule)
var gutter = $('<div>', {
const gutter = $('<div>', {
class: 'authorship-gutter ' + className,
title: author.name
})
@ -1985,8 +1986,8 @@ function iterateLine (line) {
editor.setGutterMarker(line, 'authorship-gutters', null)
}
if (currMark && currMark.textmarkers.length > 0) {
for (var i = 0; i < currMark.textmarkers.length; i++) {
let textMarker = currMark.textmarkers[i]
for (let i = 0; i < currMark.textmarkers.length; i++) {
const textMarker = currMark.textmarkers[i]
if (textMarker.userid !== currMark.gutter.userid) {
addTextMarkers.push(textMarker)
}
@ -1997,12 +1998,12 @@ editorInstance.on('update', function () {
$('.authorship-gutter:not([data-original-title])').tooltip({
container: '.CodeMirror-lines',
placement: 'right',
delay: { 'show': 500, 'hide': 100 }
delay: { show: 500, hide: 100 }
})
$('.authorship-inline:not([data-original-title])').tooltip({
container: '.CodeMirror-lines',
placement: 'bottom',
delay: { 'show': 500, 'hide': 100 }
delay: { show: 500, hide: 100 }
})
// clear tooltip which described element has been removed
$('[id^="tooltip"]').each(function (index, element) {
@ -2063,7 +2064,7 @@ var cmClient = null
var synchronized_ = null
function havePendingOperation () {
return !!((cmClient && cmClient.state && cmClient.state.hasOwnProperty('outstanding')))
return !!((cmClient && cmClient.state && Object.hasOwnProperty.call(cmClient.state, 'outstanding')))
}
socket.on('doc', function (obj) {
@ -2223,7 +2224,7 @@ function updateOnlineStatus () {
break
}
}
let id = items[i].values().id
const id = items[i].values().id
if (found) {
onlineUserList.get('id', id)[0].values(_onlineUsers[foundindex])
shortOnlineUserList.get('id', id)[0].values(_onlineUsers[foundindex])
@ -2417,19 +2418,19 @@ function buildCursor (user) {
break
}
if ($('div[data-clientid="' + user.id + '"]').length <= 0) {
let cursor = $('<div data-clientid="' + user.id + '" class="CodeMirror-other-cursor" style="display:none;"></div>')
const cursor = $('<div data-clientid="' + user.id + '" class="CodeMirror-other-cursor" style="display:none;"></div>')
cursor.attr('data-line', user.cursor.line)
cursor.attr('data-ch', user.cursor.ch)
cursor.attr('data-offset-left', 0)
cursor.attr('data-offset-top', 0)
let cursorbar = $('<div class="cursorbar">&nbsp;</div>')
const cursorbar = $('<div class="cursorbar">&nbsp;</div>')
cursorbar[0].style.height = defaultTextHeight + 'px'
cursorbar[0].style.borderLeft = '2px solid ' + user.color
var icon = '<i class="fa ' + iconClass + '"></i>'
let cursortag = $('<div class="cursortag">' + icon + '&nbsp;<span class="name">' + user.name + '</span></div>')
const cursortag = $('<div class="cursortag">' + icon + '&nbsp;<span class="name">' + user.name + '</span></div>')
// cursortag[0].style.background = color;
cursortag[0].style.color = user.color
@ -2485,15 +2486,15 @@ function buildCursor (user) {
checkCursorTag(coord, cursortag)
} else {
let cursor = $('div[data-clientid="' + user.id + '"]')
const cursor = $('div[data-clientid="' + user.id + '"]')
cursor.attr('data-line', user.cursor.line)
cursor.attr('data-ch', user.cursor.ch)
let cursorbar = cursor.find('.cursorbar')
const cursorbar = cursor.find('.cursorbar')
cursorbar[0].style.height = defaultTextHeight + 'px'
cursorbar[0].style.borderLeft = '2px solid ' + user.color
let cursortag = cursor.find('.cursortag')
const cursortag = cursor.find('.cursortag')
cursortag.find('i').removeClass().addClass('fa').addClass(iconClass)
cursortag.find('.name').text(user.name)
@ -2502,8 +2503,8 @@ function buildCursor (user) {
cursor[0].style.top = coord.top + 'px'
} else {
cursor.animate({
'left': coord.left,
'top': coord.top
left: coord.left,
top: coord.top
}, {
duration: cursorAnimatePeriod,
queue: false
@ -2712,8 +2713,8 @@ function restoreInfo () {
$(window).scrollLeft(lastInfo.edit.scroll.left)
$(window).scrollTop(lastInfo.edit.scroll.top)
} else {
let left = lastInfo.edit.scroll.left
let top = lastInfo.edit.scroll.top
const left = lastInfo.edit.scroll.left
const top = lastInfo.edit.scroll.top
editor.scrollIntoView()
editor.scrollTo(left, top)
}
@ -2723,8 +2724,8 @@ function restoreInfo () {
$(window).scrollTop(lastInfo.view.scroll.top)
break
case modeType.both:
let left = lastInfo.edit.scroll.left
let top = lastInfo.edit.scroll.top
const left = lastInfo.edit.scroll.left
const top = lastInfo.edit.scroll.top
editor.scrollIntoView()
editor.scrollTo(left, top)
ui.area.view.scrollLeft(lastInfo.view.scroll.left)
@ -2846,8 +2847,8 @@ function partialUpdate (src, tar, des) {
for (let i = 0; i < tar.length; i++) {
// copyAttribute(src[i], des[i], 'data-startline');
// copyAttribute(src[i], des[i], 'data-endline');
let rawSrc = cloneAndRemoveDataAttr(src[i])
let rawTar = cloneAndRemoveDataAttr(tar[i])
const rawSrc = cloneAndRemoveDataAttr(src[i])
const rawTar = cloneAndRemoveDataAttr(tar[i])
if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) {
start = i
break
@ -2859,8 +2860,8 @@ function partialUpdate (src, tar, des) {
for (let i = 0; i < src.length; i++) {
// copyAttribute(src[i], des[i], 'data-startline');
// copyAttribute(src[i], des[i], 'data-endline');
let rawSrc = cloneAndRemoveDataAttr(src[i])
let rawTar = cloneAndRemoveDataAttr(tar[i])
const rawSrc = cloneAndRemoveDataAttr(src[i])
const rawTar = cloneAndRemoveDataAttr(tar[i])
if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) {
start = i
break
@ -2868,12 +2869,12 @@ function partialUpdate (src, tar, des) {
}
// tar end
for (let i = 1; i <= tar.length + 1; i++) {
let srcLength = src.length
let tarLength = tar.length
const srcLength = src.length
const tarLength = tar.length
// copyAttribute(src[srcLength - i], des[srcLength - i], 'data-startline');
// copyAttribute(src[srcLength - i], des[srcLength - i], 'data-endline');
let rawSrc = cloneAndRemoveDataAttr(src[srcLength - i])
let rawTar = cloneAndRemoveDataAttr(tar[tarLength - i])
const rawSrc = cloneAndRemoveDataAttr(src[srcLength - i])
const rawTar = cloneAndRemoveDataAttr(tar[tarLength - i])
if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) {
tarEnd = tar.length - i
break
@ -2881,12 +2882,12 @@ function partialUpdate (src, tar, des) {
}
// src end
for (let i = 1; i <= src.length + 1; i++) {
let srcLength = src.length
let tarLength = tar.length
const srcLength = src.length
const tarLength = tar.length
// copyAttribute(src[srcLength - i], des[srcLength - i], 'data-startline');
// copyAttribute(src[srcLength - i], des[srcLength - i], 'data-endline');
let rawSrc = cloneAndRemoveDataAttr(src[srcLength - i])
let rawTar = cloneAndRemoveDataAttr(tar[tarLength - i])
const rawSrc = cloneAndRemoveDataAttr(src[srcLength - i])
const rawTar = cloneAndRemoveDataAttr(tar[tarLength - i])
if (!rawSrc || !rawTar || rawSrc.outerHTML !== rawTar.outerHTML) {
srcEnd = src.length - i
break
@ -3115,6 +3116,27 @@ function matchInContainer (text) {
}
}
const textCompleteKeyMap = {
Up: function () {
return false
},
Right: function () {
editor.doc.cm.execCommand('goCharRight')
},
Down: function () {
return false
},
Left: function () {
editor.doc.cm.execCommand('goCharLeft')
},
Enter: function () {
return false
},
Backspace: function () {
editor.doc.cm.execCommand('delCharBefore')
}
}
$(editor.getInputField())
.textcomplete([
{ // emoji strategy
@ -3316,29 +3338,10 @@ $(editor.getInputField())
},
'textComplete:show': function (e) {
$(this).data('autocompleting', true)
editor.setOption('extraKeys', {
'Up': function () {
return false
},
'Right': function () {
editor.doc.cm.execCommand('goCharRight')
},
'Down': function () {
return false
},
'Left': function () {
editor.doc.cm.execCommand('goCharLeft')
},
'Enter': function () {
return false
},
'Backspace': function () {
editor.doc.cm.execCommand('delCharBefore')
}
})
editor.addKeyMap(textCompleteKeyMap)
},
'textComplete:hide': function (e) {
$(this).data('autocompleting', false)
editor.setOption('extraKeys', editorInstance.defaultExtraKeys)
editor.removeKeyMap(textCompleteKeyMap)
}
})

View File

@ -1,6 +1,6 @@
import modeType from './modeType'
let state = {
const state = {
syncscroll: true,
currentMode: modeType.view,
nightMode: false

View File

@ -2,7 +2,12 @@ window.domain = '<%- domain %>'
window.urlpath = '<%- urlpath %>'
window.debug = <%- debug %>
window.version = '<%- version %>'
window.plantumlServer = '<%- plantumlServer %>'
window.allowedUploadMimeTypes = <%- JSON.stringify(allowedUploadMimeTypes) %>
window.defaultUseHardbreak = <%- defaultUseHardbreak %>
window.linkifyHeaderStyle = '<%- linkifyHeaderStyle %>'
window.DROPBOX_APP_KEY = '<%- DROPBOX_APP_KEY %>'

View File

@ -1,4 +1,4 @@
let config = {
const config = {
docmaxlength: null
}

View File

@ -1,7 +1,11 @@
/* global CodeMirror, $, editor, Cookies */
import * as utils from './utils'
import config from './config'
import statusBarTemplate from './statusbar.html'
import toolBarTemplate from './toolbar.html'
import './markdown-lint'
import { initTableEditor } from './table-editor'
import { options, Alignment, FormatType } from '@susisu/mte-kernel'
/* config section */
const isMac = CodeMirror.keyMap.default === CodeMirror.keyMap.macDefault
@ -75,8 +79,8 @@ export default class Editor {
},
'Cmd-Left': 'goLineLeftSmart',
'Cmd-Right': 'goLineRight',
'Home': 'goLineLeftSmart',
'End': 'goLineRight',
Home: 'goLineLeftSmart',
End: 'goLineRight',
'Ctrl-C': function (cm) {
if (!isMac && cm.getOption('keyMap').substr(0, 3) === 'vim') {
document.execCommand('copy')
@ -158,6 +162,19 @@ export default class Editor {
var makeLine = $('#makeLine')
var makeComment = $('#makeComment')
var insertRow = $('#insertRow')
var deleteRow = $('#deleteRow')
var moveRowUp = $('#moveRowUp')
var moveRowDown = $('#moveRowDown')
var insertColumn = $('#insertColumn')
var deleteColumn = $('#deleteColumn')
var moveColumnLeft = $('#moveColumnLeft')
var moveColumnRight = $('#moveColumnRight')
var alignLeft = $('#alignLeft')
var alignCenter = $('#alignCenter')
var alignRight = $('#alignRight')
var alignNone = $('#alignNone')
makeBold.click(() => {
utils.wrapTextWith(this.editor, this.editor, '**')
this.editor.focus()
@ -217,6 +234,72 @@ export default class Editor {
makeComment.click(() => {
utils.insertText(this.editor, '> []')
})
// table tools UI
const opts = options({
smartCursor: true,
formatType: FormatType.NORMAL
})
insertRow.click(() => {
this.tableEditor.insertRow(opts)
this.editor.focus()
})
deleteRow.click(() => {
this.tableEditor.deleteRow(opts)
this.editor.focus()
})
moveRowUp.click(() => {
this.tableEditor.moveRow(-1, opts)
this.editor.focus()
})
moveRowDown.click(() => {
this.tableEditor.moveRow(1, opts)
this.editor.focus()
})
insertColumn.click(() => {
this.tableEditor.insertColumn(opts)
this.editor.focus()
})
deleteColumn.click(() => {
this.tableEditor.deleteColumn(opts)
this.editor.focus()
})
moveColumnLeft.click(() => {
this.tableEditor.moveColumn(-1, opts)
this.editor.focus()
})
moveColumnRight.click(() => {
this.tableEditor.moveColumn(1, opts)
this.editor.focus()
})
alignLeft.click(() => {
this.tableEditor.alignColumn(Alignment.LEFT, opts)
this.editor.focus()
})
alignCenter.click(() => {
this.tableEditor.alignColumn(Alignment.CENTER, opts)
this.editor.focus()
})
alignRight.click(() => {
this.tableEditor.alignColumn(Alignment.RIGHT, opts)
this.editor.focus()
})
alignNone.click(() => {
this.tableEditor.alignColumn(Alignment.NONE, opts)
this.editor.focus()
})
}
addStatusBar () {
@ -230,6 +313,7 @@ export default class Editor {
this.statusLength = this.statusBar.find('.status-length')
this.statusTheme = this.statusBar.find('.status-theme')
this.statusSpellcheck = this.statusBar.find('.status-spellcheck')
this.statusLinter = this.statusBar.find('.status-linter')
this.statusPreferences = this.statusBar.find('.status-preferences')
this.statusPanel = this.editor.addPanel(this.statusBar[0], {
position: 'bottom'
@ -239,6 +323,7 @@ export default class Editor {
this.setKeymap()
this.setTheme()
this.setSpellcheck()
this.setLinter()
this.setPreferences()
}
@ -497,6 +582,42 @@ export default class Editor {
}
}
toggleLinter (enable) {
const gutters = this.editor.getOption('gutters')
const lintGutter = 'CodeMirror-lint-markers'
if (enable) {
if (!gutters.includes(lintGutter)) {
this.editor.setOption('gutters', [lintGutter, ...gutters])
}
Cookies.set('linter', true, {
expires: 365
})
} else {
this.editor.setOption('gutters', gutters.filter(g => g !== lintGutter))
Cookies.remove('linter')
}
this.editor.setOption('lint', enable)
}
setLinter () {
const linterToggle = this.statusLinter.find('.ui-linter-toggle')
const updateLinterStatus = (enable) => {
linterToggle.toggleClass('active', enable)
}
linterToggle.click(() => {
const lintEnable = this.editor.getOption('lint')
this.toggleLinter.bind(this)(!lintEnable)
updateLinterStatus(!lintEnable)
})
const enable = !!Cookies.get('linter')
this.toggleLinter.bind(this)(enable)
updateLinterStatus(enable)
}
resetEditorKeymapToBrowserKeymap () {
var keymap = this.editor.getOption('keyMap')
if (!this.jumpToAddressBarKeymapValue) {
@ -512,6 +633,7 @@ export default class Editor {
this.jumpToAddressBarKeymapValue = null
}
}
setOverrideBrowserKeymap () {
var overrideBrowserKeymap = $(
'.ui-preferences-override-browser-keymap label > input[type="checkbox"]'
@ -582,6 +704,8 @@ export default class Editor {
placeholder: "← Start by entering a title here\n===\nVisit /features if you don't know what to do.\nHappy hacking :)"
})
this.tableEditor = initTableEditor(this.editor)
return this.editor
}

View File

@ -0,0 +1,48 @@
/* global CodeMirror */
// load CM lint plugin explicitly
import '@hackmd/codemirror/addon/lint/lint'
import './lint.css'
window.markdownit = require('markdown-it')
// eslint-disable-next-line
require('script-loader!markdownlint');
(function (mod) {
mod(CodeMirror)
})(function (CodeMirror) {
function validator (text) {
return lint(text).map(error => {
const {
ruleNames,
ruleDescription,
lineNumber: ln,
errorRange
} = error
const lineNumber = ln - 1
let start = 0; let end = -1
if (errorRange) {
[start, end] = errorRange.map(r => r - 1)
}
return {
messageHTML: `${ruleNames.join('/')}: ${ruleDescription}`,
severity: 'error',
from: CodeMirror.Pos(lineNumber, start),
to: CodeMirror.Pos(lineNumber, end)
}
})
}
CodeMirror.registerHelper('lint', 'markdown', validator)
})
function lint (content) {
const { content: errors } = window.markdownlint.sync({
strings: {
content
}
})
return errors
}

View File

@ -0,0 +1,73 @@
/* The lint marker gutter */
.CodeMirror-lint-markers {
width: 16px;
}
.CodeMirror-lint-tooltip {
background-color: #333333;
border: 1px solid #eeeeee;
border-radius: 4px;
color: white;
font-family: "Source Code Pro", Consolas, monaco, monospace;
font-size: 10pt;
overflow: hidden;
padding: 2px 5px;
position: fixed;
white-space: pre;
white-space: pre-wrap;
z-index: 100;
max-width: 600px;
opacity: 0;
transition: opacity .4s;
-moz-transition: opacity .4s;
-webkit-transition: opacity .4s;
-o-transition: opacity .4s;
-ms-transition: opacity .4s;
}
.CodeMirror-lint-mark-error, .CodeMirror-lint-mark-warning {
background-position: left bottom;
background-repeat: repeat-x;
}
.CodeMirror-lint-mark-error {
background-image: url(/images/lint/mark-error.png);
}
.CodeMirror-lint-mark-warning {
background-image: url(/images/lint/mark-warning.png);
}
.CodeMirror-lint-marker-error, .CodeMirror-lint-marker-warning {
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
display: inline-block;
height: 16px;
width: 16px;
vertical-align: middle;
position: relative;
margin-left: 5px;
}
.CodeMirror-lint-message-error, .CodeMirror-lint-message-warning {
padding-left: 20px;
background-position: top left;
background-repeat: no-repeat;
background-position-y: 2px;
}
.CodeMirror-lint-marker-error, .CodeMirror-lint-message-error {
background-image: url(/images/lint/message-error.png);
}
.CodeMirror-lint-marker-warning, .CodeMirror-lint-message-warning {
background-image: url(/images/lint/message-warning.png);
}
.CodeMirror-lint-marker-multiple {
background-image: url(/images/lint/mark-multiple.png);
background-repeat: no-repeat;
background-position: right bottom;
width: 100%; height: 100%;
}

View File

@ -37,5 +37,8 @@
<div class="status-spellcheck">
<a class="ui-spellcheck-toggle" title="Toggle spellcheck"><i class="fa fa-check fa-fw"></i></a>
</div>
<div class="status-linter">
<a class="ui-linter-toggle" title="Toggle linter"><i class="fa fa-lightbulb-o fa-fw"></i></a>
</div>
</div>
</div>

View File

@ -0,0 +1,205 @@
/* global CodeMirror, $ */
import { TableEditor, Point, options, Alignment, FormatType } from '@susisu/mte-kernel'
// port of the code from: https://github.com/susisu/mte-demo/blob/master/src/main.js
// text editor interface
// see https://doc.esdoc.org/github.com/susisu/mte-kernel/class/lib/text-editor.js~ITextEditor.html
class TextEditorInterface {
constructor (editor) {
this.editor = editor
this.doc = editor.getDoc()
this.transaction = false
this.onDidFinishTransaction = null
}
getCursorPosition () {
const { line, ch } = this.doc.getCursor()
return new Point(line, ch)
}
setCursorPosition (pos) {
this.doc.setCursor({ line: pos.row, ch: pos.column })
}
setSelectionRange (range) {
this.doc.setSelection(
{ line: range.start.row, ch: range.start.column },
{ line: range.end.row, ch: range.end.column }
)
}
getLastRow () {
return this.doc.lineCount() - 1
}
acceptsTableEdit () {
return true
}
getLine (row) {
return this.doc.getLine(row)
}
insertLine (row, line) {
const lastRow = this.getLastRow()
if (row > lastRow) {
const lastLine = this.getLine(lastRow)
this.doc.replaceRange(
'\n' + line,
{ line: lastRow, ch: lastLine.length },
{ line: lastRow, ch: lastLine.length }
)
} else {
this.doc.replaceRange(
line + '\n',
{ line: row, ch: 0 },
{ line: row, ch: 0 }
)
}
}
deleteLine (row) {
const lastRow = this.getLastRow()
if (row >= lastRow) {
if (lastRow > 0) {
const preLastLine = this.getLine(lastRow - 1)
const lastLine = this.getLine(lastRow)
this.doc.replaceRange(
'',
{ line: lastRow - 1, ch: preLastLine.length },
{ line: lastRow, ch: lastLine.length }
)
} else {
const lastLine = this.getLine(lastRow)
this.doc.replaceRange(
'',
{ line: lastRow, ch: 0 },
{ line: lastRow, ch: lastLine.length }
)
}
} else {
this.doc.replaceRange(
'',
{ line: row, ch: 0 },
{ line: row + 1, ch: 0 }
)
}
}
replaceLines (startRow, endRow, lines) {
const lastRow = this.getLastRow()
if (endRow > lastRow) {
const lastLine = this.getLine(lastRow)
this.doc.replaceRange(
lines.join('\n'),
{ line: startRow, ch: 0 },
{ line: lastRow, ch: lastLine.length }
)
} else {
this.doc.replaceRange(
lines.join('\n') + '\n',
{ line: startRow, ch: 0 },
{ line: endRow, ch: 0 }
)
}
}
transact (func) {
this.transaction = true
func()
this.transaction = false
if (this.onDidFinishTransaction) {
this.onDidFinishTransaction.call(undefined)
}
}
}
export function initTableEditor (editor) {
// create an interface to the text editor
const editorIntf = new TextEditorInterface(editor)
// create a table editor object
const tableEditor = new TableEditor(editorIntf)
// options for the table editor
const opts = options({
smartCursor: true,
formatType: FormatType.NORMAL
})
// keymap of the commands
// from https://github.com/susisu/mte-demo/blob/master/src/main.js
const keyMap = CodeMirror.normalizeKeyMap({
Tab: () => { tableEditor.nextCell(opts) },
'Shift-Tab': () => { tableEditor.previousCell(opts) },
Enter: () => { tableEditor.nextRow(opts) },
'Ctrl-Enter': () => { tableEditor.escape(opts) },
'Cmd-Enter': () => { tableEditor.escape(opts) },
'Shift-Ctrl-Left': () => { tableEditor.alignColumn(Alignment.LEFT, opts) },
'Shift-Cmd-Left': () => { tableEditor.alignColumn(Alignment.LEFT, opts) },
'Shift-Ctrl-Right': () => { tableEditor.alignColumn(Alignment.RIGHT, opts) },
'Shift-Cmd-Right': () => { tableEditor.alignColumn(Alignment.RIGHT, opts) },
'Shift-Ctrl-Up': () => { tableEditor.alignColumn(Alignment.CENTER, opts) },
'Shift-Cmd-Up': () => { tableEditor.alignColumn(Alignment.CENTER, opts) },
'Shift-Ctrl-Down': () => { tableEditor.alignColumn(Alignment.NONE, opts) },
'Shift-Cmd-Down': () => { tableEditor.alignColumn(Alignment.NONE, opts) },
'Ctrl-Left': () => { tableEditor.moveFocus(0, -1, opts) },
'Cmd-Left': () => { tableEditor.moveFocus(0, -1, opts) },
'Ctrl-Right': () => { tableEditor.moveFocus(0, 1, opts) },
'Cmd-Right': () => { tableEditor.moveFocus(0, 1, opts) },
'Ctrl-Up': () => { tableEditor.moveFocus(-1, 0, opts) },
'Cmd-Up': () => { tableEditor.moveFocus(-1, 0, opts) },
'Ctrl-Down': () => { tableEditor.moveFocus(1, 0, opts) },
'Cmd-Down': () => { tableEditor.moveFocus(1, 0, opts) },
'Ctrl-K Ctrl-I': () => { tableEditor.insertRow(opts) },
'Cmd-K Cmd-I': () => { tableEditor.insertRow(opts) },
'Ctrl-L Ctrl-I': () => { tableEditor.deleteRow(opts) },
'Cmd-L Cmd-I': () => { tableEditor.deleteRow(opts) },
'Ctrl-K Ctrl-J': () => { tableEditor.insertColumn(opts) },
'Cmd-K Cmd-J': () => { tableEditor.insertColumn(opts) },
'Ctrl-L Ctrl-J': () => { tableEditor.deleteColumn(opts) },
'Cmd-L Cmd-J': () => { tableEditor.deleteColumn(opts) },
'Alt-Shift-Ctrl-Left': () => { tableEditor.moveColumn(-1, opts) },
'Alt-Shift-Cmd-Left': () => { tableEditor.moveColumn(-1, opts) },
'Alt-Shift-Ctrl-Right': () => { tableEditor.moveColumn(1, opts) },
'Alt-Shift-Cmd-Right': () => { tableEditor.moveColumn(1, opts) },
'Alt-Shift-Ctrl-Up': () => { tableEditor.moveRow(-1, opts) },
'Alt-Shift-Cmd-Up': () => { tableEditor.moveRow(-1, opts) },
'Alt-Shift-Ctrl-Down': () => { tableEditor.moveRow(1, opts) },
'Alt-Shift-Cmd-Down': () => { tableEditor.moveRow(1, opts) }
})
let lastActive
// enable keymap if the cursor is in a table
function updateActiveState () {
const tableTools = $('.toolbar .table-tools')
const active = tableEditor.cursorIsInTable(opts)
// avoid to update if state not changed
if (lastActive === active) {
return
}
if (active) {
tableTools.show()
tableTools.parent().scrollLeft(tableTools.parent()[0].scrollWidth)
editor.addKeyMap(keyMap)
} else {
tableTools.hide()
editor.removeKeyMap(keyMap)
tableEditor.resetSmartCursor()
}
lastActive = active
}
// event subscriptions
editor.on('cursorActivity', () => {
if (!editorIntf.transaction) {
updateActiveState()
}
})
editor.on('changes', () => {
if (!editorIntf.transaction) {
updateActiveState()
}
})
editorIntf.onDidFinishTransaction = () => {
updateActiveState()
}
return tableEditor
}

View File

@ -44,5 +44,47 @@
<i class="fa fa-comment fa-fw"></i>
</a>
</div>
<span class="btn-group table-tools hidden-xs" style="display: none;">
<span class="separator" style="margin-left: -10px;">|</span>
<span>Row</span>
<a id="insertRow" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Insert Row">
<i class="fa fa-plus-circle fa-fw"></i>
</a>
<a id="deleteRow" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Delete Row">
<i class="fa fa-minus-circle fa-fw"></i>
</a>
<a id="moveRowUp" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Move Row Up">
<i class="fa fa-long-arrow-up fa-fw"></i>
</a>
<a id="moveRowDown" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Move Row Down">
<i class="fa fa-long-arrow-down fa-fw"></i>
</a>
<span>Column</span>
<a id="insertColumn" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Insert Column">
<i class="fa fa-plus-circle fa-fw"></i>
</a>
<a id="deleteColumn" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Delete Column">
<i class="fa fa-minus-circle fa-fw"></i>
</a>
<a id="moveColumnLeft" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Move Column Left">
<i class="fa fa-long-arrow-left fa-fw"></i>
</a>
<a id="moveColumnRight" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Move Column Right">
<i class="fa fa-long-arrow-right fa-fw"></i>
</a>
<span>Alignment</span>
<a id="alignLeft" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Align Left">
<i class="fa fa-align-left fa-fw"></i>
</a>
<a id="alignCenter" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Align Center">
<i class="fa fa-align-center fa-fw"></i>
</a>
<a id="alignRight" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Align Right">
<i class="fa fa-align-right fa-fw"></i>
</a>
<a id="alignNone" class="btn btn-sm btn-dark text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" title="Align None">
<i class="fa fa-ban fa-fw"></i>
</a>
</span>
</div>
</div>

View File

@ -1,6 +1,7 @@
/*
* Global UI elements references
*/
/* global $ */
export const getUIElements = () => ({
spinner: $('.ui-spinner'),

View File

@ -1,19 +1,20 @@
/* global CodeMirror, editor */
const wrapSymbols = ['*', '_', '~', '^', '+', '=']
export function wrapTextWith (editor, cm, symbol) {
if (!cm.getSelection()) {
return CodeMirror.Pass
} else {
let ranges = cm.listSelections()
const ranges = cm.listSelections()
for (let i = 0; i < ranges.length; i++) {
let range = ranges[i]
const range = ranges[i]
if (!range.empty()) {
const from = range.from()
const to = range.to()
if (symbol !== 'Backspace') {
let selection = cm.getRange(from, to)
let anchorIndex = editor.indexFromPos(ranges[i].anchor)
let headIndex = editor.indexFromPos(ranges[i].head)
const selection = cm.getRange(from, to)
const anchorIndex = editor.indexFromPos(ranges[i].anchor)
const headIndex = editor.indexFromPos(ranges[i].head)
cm.replaceRange(symbol + selection + symbol, from, to, '+input')
if (anchorIndex > headIndex) {
ranges[i].anchor.ch += symbol.length
@ -24,18 +25,18 @@ export function wrapTextWith (editor, cm, symbol) {
}
cm.setSelections(ranges)
} else {
let preEndPos = {
const preEndPos = {
line: to.line,
ch: to.ch + symbol.length
}
let preText = cm.getRange(to, preEndPos)
let preIndex = wrapSymbols.indexOf(preText)
let postEndPos = {
const preText = cm.getRange(to, preEndPos)
const preIndex = wrapSymbols.indexOf(preText)
const postEndPos = {
line: from.line,
ch: from.ch - symbol.length
}
let postText = cm.getRange(postEndPos, from)
let postIndex = wrapSymbols.indexOf(postText)
const postText = cm.getRange(postEndPos, from)
const postIndex = wrapSymbols.indexOf(postText)
// check if surround symbol are list in array and matched
if (preIndex > -1 && postIndex > -1 && preIndex === postIndex) {
cm.replaceRange('', to, preEndPos, '+delete')
@ -48,25 +49,25 @@ export function wrapTextWith (editor, cm, symbol) {
}
export function insertText (cm, text, cursorEnd = 0) {
let cursor = cm.getCursor()
const cursor = cm.getCursor()
cm.replaceSelection(text, cursor, cursor)
cm.focus()
cm.setCursor({ line: cursor.line, ch: cursor.ch + cursorEnd })
}
export function insertLink (cm, isImage) {
let cursor = cm.getCursor()
let ranges = cm.listSelections()
const cursor = cm.getCursor()
const ranges = cm.listSelections()
const linkEnd = '](https://)'
const symbol = (isImage) ? '![' : '['
for (let i = 0; i < ranges.length; i++) {
let range = ranges[i]
const range = ranges[i]
if (!range.empty()) {
const from = range.from()
const to = range.to()
let anchorIndex = editor.indexFromPos(ranges[i].anchor)
let headIndex = editor.indexFromPos(ranges[i].head)
const anchorIndex = editor.indexFromPos(ranges[i].anchor)
const headIndex = editor.indexFromPos(ranges[i].head)
let selection = cm.getRange(from, to)
selection = symbol + selection + linkEnd
cm.replaceRange(selection, from, to)
@ -87,9 +88,9 @@ export function insertLink (cm, isImage) {
}
export function insertHeader (cm) {
let cursor = cm.getCursor()
let startOfLine = { line: cursor.line, ch: 0 }
let startOfLineText = cm.getRange(startOfLine, { line: cursor.line, ch: 1 })
const cursor = cm.getCursor()
const startOfLine = { line: cursor.line, ch: 0 }
const startOfLineText = cm.getRange(startOfLine, { line: cursor.line, ch: 1 })
// See if it is already a header
if (startOfLineText === '#') {
cm.replaceRange('#', startOfLine, startOfLine)
@ -100,11 +101,11 @@ export function insertHeader (cm) {
}
export function insertOnStartOfLines (cm, symbol) {
let cursor = cm.getCursor()
let ranges = cm.listSelections()
const cursor = cm.getCursor()
const ranges = cm.listSelections()
for (let i = 0; i < ranges.length; i++) {
let range = ranges[i]
const range = ranges[i]
if (!range.empty()) {
const from = range.from()
const to = range.to()

View File

@ -110,6 +110,31 @@ md.use(markdownitContainer, 'success', { render: renderContainer })
md.use(markdownitContainer, 'info', { render: renderContainer })
md.use(markdownitContainer, 'warning', { render: renderContainer })
md.use(markdownitContainer, 'danger', { render: renderContainer })
md.use(markdownitContainer, 'spoiler', {
validate: function (params) {
return params.trim().match(/^spoiler(\s+.*)?$/)
},
render: function (tokens, idx) {
const m = tokens[idx].info.trim().match(/^spoiler(\s+.*)?$/)
if (tokens[idx].nesting === 1) {
// opening tag
const startline = tokens[idx].map[0] + 1
const endline = tokens[idx].map[1]
const partClass = `class="part raw" data-startline="${startline}" data-endline="${endline}"`
const summary = m[1] && m[1].trim()
if (summary) {
return `<details ${partClass}><summary>${md.utils.escapeHtml(summary)}</summary>\n`
} else {
return `<details ${partClass}>\n`
}
} else {
// closing tag
return '</details>\n'
}
}
})
window.preventSyncScrollToEdit = false
window.preventSyncScrollToView = false
@ -155,12 +180,12 @@ const buildMap = _.throttle(buildMapInner, buildMapThrottle)
// Optimizations are required only for big texts.
function buildMapInner (callback) {
if (!viewArea || !markdownArea) return
let i, offset, nonEmptyList, pos, a, b, _lineHeightMap, linesCount, acc, _scrollMap
let i, pos, a, b, acc
offset = viewArea.scrollTop() - viewArea.offset().top
_scrollMap = []
nonEmptyList = []
_lineHeightMap = []
const offset = viewArea.scrollTop() - viewArea.offset().top
const _scrollMap = []
const nonEmptyList = []
const _lineHeightMap = []
viewTop = 0
viewBottom = viewArea[0].scrollHeight - viewArea.height()
@ -181,7 +206,7 @@ function buildMapInner (callback) {
acc += Math.round(h / lineHeight)
}
_lineHeightMap.push(acc)
linesCount = acc
const linesCount = acc
for (i = 0; i < linesCount; i++) {
_scrollMap.push(-1)
@ -331,11 +356,11 @@ export function syncScrollToView (event, preventAnimate) {
}
if (viewScrolling) return
let lineNo, posTo
let posTo
let topDiffPercent, posToNextDiff
const scrollInfo = editor.getScrollInfo()
const textHeight = editor.defaultTextHeight()
lineNo = Math.floor(scrollInfo.top / textHeight)
const lineNo = Math.floor(scrollInfo.top / textHeight)
// if reach the last line, will start lerp to the bottom
const diffToBottom = (scrollInfo.top + scrollInfo.clientHeight) - (scrollInfo.height - textHeight)
if (scrollInfo.height > scrollInfo.clientHeight && diffToBottom > 0) {

View File

@ -20,7 +20,7 @@ whiteList['style'] = []
// allow kbd tag
whiteList['kbd'] = []
// allow ifram tag with some safe attributes
whiteList['iframe'] = ['allowfullscreen', 'name', 'referrerpolicy', 'sandbox', 'src', 'width', 'height']
whiteList['iframe'] = ['allowfullscreen', 'name', 'referrerpolicy', 'src', 'width', 'height']
// allow summary tag
whiteList['summary'] = []
// allow ruby tag

View File

@ -26,7 +26,7 @@ function extend () {
for (const source of arguments) {
for (const key in source) {
if (source.hasOwnProperty(key)) {
if (Object.hasOwnProperty.call(source, key)) {
target[key] = source[key]
}
}
@ -49,6 +49,15 @@ const deps = [{
}
}]
// options from yaml meta
const meta = JSON.parse($('#meta').text())
// breaks
if (typeof meta.breaks === 'boolean') {
md.options.breaks = meta.breaks
} else {
md.options.breaks = window.defaultUseHardbreak
}
const slideOptions = {
separator: '^(\r\n?|\n)---(\r\n?|\n)$',
verticalSeparator: '^(\r\n?|\n)----(\r\n?|\n)$'
@ -70,21 +79,19 @@ const defaultOptions = {
dependencies: deps
}
// options from yaml meta
const meta = JSON.parse($('#meta').text())
var options = meta.slideOptions || {}
if (options.hasOwnProperty('spotlight')) {
if (Object.hasOwnProperty.call(options, 'spotlight')) {
defaultOptions.dependencies.push({
src: `${serverurl}/build/reveal.js/plugin/spotlight/spotlight.js`
})
}
if (options.hasOwnProperty('allottedTime') || options.hasOwnProperty('allottedMinutes')) {
if (Object.hasOwnProperty.call(options, 'allottedTime') || Object.hasOwnProperty.call(options, 'allottedMinutes')) {
defaultOptions.dependencies.push({
src: `${serverurl}/build/reveal.js/plugin/elapsed-time-bar/elapsed-time-bar.js`
})
if (options.hasOwnProperty('allottedMinutes')) {
if (Object.hasOwnProperty.call(options, 'allottedMinutes')) {
options.allottedTime = options.allottedMinutes * 60 * 1000
}
}
@ -103,12 +110,6 @@ if (meta.dir && typeof meta.dir === 'string' && meta.dir === 'rtl') {
} else {
options.rtl = false
}
// breaks
if (typeof meta.breaks === 'boolean' && !meta.breaks) {
md.options.breaks = false
} else {
md.options.breaks = true
}
// options from URL query string
const queryOptions = Reveal.getQueryHash() || {}
@ -123,14 +124,14 @@ window.viewAjaxCallback = () => {
function renderSlide (event) {
if (window.location.search.match(/print-pdf/gi)) {
const slides = $('.slides')
let title = document.title
const title = document.title
finishView(slides)
document.title = title
Reveal.layout()
} else {
const markdown = $(event.currentSlide)
if (!markdown.attr('data-rendered')) {
let title = document.title
const title = document.title
finishView(markdown)
markdown.attr('data-rendered', 'true')
document.title = title

View File

@ -1,28 +1,24 @@
import base64url from 'base64url'
let uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function checkNoteIdValid (id) {
let result = id.match(uuidRegex)
if (result && result.length === 1) {
return true
} else {
return false
}
const result = id.match(uuidRegex)
return !!(result && result.length === 1)
}
export function encodeNoteId (id) {
// remove dashes in UUID and encode in url-safe base64
let str = id.replace(/-/g, '')
let hexStr = Buffer.from(str, 'hex')
const str = id.replace(/-/g, '')
const hexStr = Buffer.from(str, 'hex')
return base64url.encode(hexStr)
}
export function decodeNoteId (encodedId) {
// decode from url-safe base64
let id = base64url.toBuffer(encodedId).toString('hex')
const id = base64url.toBuffer(encodedId).toString('hex')
// add dashes between the UUID string parts
let idParts = []
const idParts = []
idParts.push(id.substr(0, 8))
idParts.push(id.substr(8, 4))
idParts.push(id.substr(12, 4))

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 358 KiB

View File

@ -15,12 +15,12 @@
<a id="permissionLabel" class="ui-permission-label text-uppercase" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
</a>
<ul class="dropdown-menu" aria-labelledby="permissionLabel">
<li class="ui-permission-freely"<% if(!allowAnonymous && !allowAnonymousEdits) { %> style="display: none;"<% } %>><a><i class="fa fa-leaf fa-fw"></i> Freely - Anyone can edit</a></li>
<li class="ui-permission-editable"><a><i class="fa fa-shield fa-fw"></i> Editable - Signed-in people can edit</a></li>
<li class="ui-permission-limited"><a><i class="fa fa-id-card fa-fw"></i> Limited - Signed-in people can edit (forbid guests)</a></li>
<li class="ui-permission-locked"><a><i class="fa fa-lock fa-fw"></i> Locked - Only owner can edit</a></li>
<li class="ui-permission-protected"><a><i class="fa fa-umbrella fa-fw"></i> Protected - Only owner can edit (forbid guests)</a></li>
<li class="ui-permission-private"><a><i class="fa fa-hand-stop-o fa-fw"></i> Private - Only owner can view &amp; edit</a></li>
<li class="ui-permission-freely"<% if(!('freely' in permission)) { %> style="display: none;"<% } %>><a><i class="fa fa-leaf fa-fw"></i> Freely - Anyone can edit</a></li>
<li class="ui-permission-editable"<% if(!('editable' in permission)) { %> style="display: none;"<% } %>><a><i class="fa fa-shield fa-fw"></i> Editable - Signed-in people can edit</a></li>
<li class="ui-permission-limited"<% if(!('limited' in permission)) { %> style="display: none;"<% } %>><a><i class="fa fa-id-card fa-fw"></i> Limited - Signed-in people can edit (forbid guests)</a></li>
<li class="ui-permission-locked"<% if(!('locked' in permission)) { %> style="display: none;"<% } %>><a><i class="fa fa-lock fa-fw"></i> Locked - Only owner can edit</a></li>
<li class="ui-permission-protected"<% if(!('protected' in permission)) { %> style="display: none;"<% } %>><a><i class="fa fa-umbrella fa-fw"></i> Protected - Only owner can edit (forbid guests)</a></li>
<li class="ui-permission-private"<% if(!('private' in permission)) { %> style="display: none;"<% } %>><a><i class="fa fa-hand-stop-o fa-fw"></i> Private - Only owner can view &amp; edit</a></li>
<li class="divider"></li>
<li class="ui-delete-note"><a><i class="fa fa-trash-o fa-fw"></i> Delete this note</a></li>
</ul>

View File

@ -1,7 +1,7 @@
<script src="<%= serverURL %>/js/mathjax-config-extra.js"></script>
<% if(useCDN) { %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/spin.js/2.3.2/spin.min.js" integrity="sha256-PieqE0QdEDMppwXrTzSZQr6tWFX3W5KkyRVyF1zN3eg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.4.0/velocity.min.js" integrity="sha256-bhm0lgEt6ITaZCDzZpkr/VXVrLa5RP4u9v2AYsbzSUk=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.4.0/js/bootstrap.min.js" integrity="sha256-kJrlY+s09+QoWjpkOrXXwhxeaoDz9FW5SaxF8I0DibQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-mousewheel/3.1.13/jquery.mousewheel.min.js" integrity="sha256-jnOjDTXIPqall8M0MyTSt98JetJuZ7Yu+1Jm7hLTF7U=" crossorigin="anonymous" defer></script>
@ -9,17 +9,21 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/Safe.js" integrity="sha256-0ygBUDksNDXZS4vm5HMNH1a33KUu6QT1cdNTN+ZLF+4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.0/mermaid.min.js" integrity="sha256-M3OC0Q6g4/+Q4j73OvnsnA+lMkdAE5KgupRHqTiPbnI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.2.3/mermaid.min.js" integrity="sha256-4s3fF5e1iWRLtiV7mRev7n17oALqqDHbWrNqF3/r7jU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.2/lodash.min.js" integrity="sha256-Cv5v4i4SuYvwRYzIONifZjoc99CkwfncROMSWat1cVA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.1.1/socket.io.js" integrity="sha256-ji09tECORKvr8xB9iCl8DJ8iNMLriDchC1+p+yt1hSs=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.2/handlebars.min.js" integrity="sha256-ngJY93C4H39YbmrWhnLzSyiepRuQDVKDNCWO2iyMzFw=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/highlight.min.js" integrity="sha256-1zu+3BnLYV9LdiY85uXMzii3bdrkelyp37e0ZyTAQh0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gist-embed/2.6.0/gist-embed.min.js" integrity="sha256-KyF2D6xPIJUW5sUDSs93vWyZm+1RzIpKCexxElmxl8g=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.7.0/viz.js" integrity="sha256-8t+rndrF+TU4JtelmOH1lDHTMe2ovhO2UbzDArp5lY8=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/1000hz-bootstrap-validator/0.11.8/validator.min.js" integrity="sha256-LHeY7YoYJ0SSXbCx7sR14Pqna+52moaH3bhv0Mjzd/M=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/abcjs/3.1.1/abcjs_basic-min.js" integrity="sha256-Sq1r2XXWXQoShQKsS0Wrf5r7fRkErd9Fat9vHYeU68s=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/2.1.2/viz.js" integrity="sha256-8RHyK+AFzq9iXwbFo2unqidwPbwHU5FFWe3RwkcVtuU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/2.1.2/full.render.js" integrity="sha256-Ogqs510LFnekr9o7OLdpelaaAmNss9egQRTyzCqV2NQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/abcjs/3.1.1/abcjs_basic-min.js" integrity="sha256-Sq1r2XXWXQoShQKsS0Wrf5r7fRkErd9Fat9vHYeU68s=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega/5.4.0/vega.min.js" integrity="sha256-PrkRj4B3I5V9yHBLdO3jyyqNUwSKS1CXXIh3VrnFPEU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-lite/3.4.0/vega-lite.min.js" integrity="sha256-ro+FWr16NboXJ5rSwInNli1P16ObUXnWUJMgKc8KnHI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-embed/4.2.2/vega-embed.min.js" integrity="sha256-FvIpRBgWEczIjFNpbshtVJbx3QlxqxkBkf+xqZeOxUU=" crossorigin="anonymous" defer></script>
<%- include ../build/index-scripts %>
<% } else { %>
<script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script>

View File

@ -48,7 +48,7 @@
<div id="ui-toc-affix" class="ui-affix-toc ui-toc-dropdown unselectable hidden-print" data-spy="affix" style="top:17px;display:none;" {{{lang}}} {{{dir}}}>
{{{ui-toc-affix}}}
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.4.0/js/bootstrap.min.js" integrity="sha256-kJrlY+s09+QoWjpkOrXXwhxeaoDz9FW5SaxF8I0DibQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gist-embed/2.6.0/gist-embed.min.js" integrity="sha256-KyF2D6xPIJUW5sUDSs93vWyZm+1RzIpKCexxElmxl8g=" crossorigin="anonymous" defer></script>
<script>

View File

@ -1,10 +1,10 @@
<% if(useCDN) { %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.4.0/velocity.min.js" integrity="sha256-bhm0lgEt6ITaZCDzZpkr/VXVrLa5RP4u9v2AYsbzSUk=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.5.2/velocity.min.js" integrity="sha256-1HqoI76JGKA17K0C0s9K8L/iy8PAC43KVLt1hRD/Ojc=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.4.0/js/bootstrap.min.js" integrity="sha256-kJrlY+s09+QoWjpkOrXXwhxeaoDz9FW5SaxF8I0DibQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/list.pagination.js/0.1.1/list.pagination.min.js" integrity="sha256-WwTza96H3BgcQTfEfxX7MFaFc/dZA0QrPRKDRLdFHJo=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/3.5.2/select2.min.js" integrity="sha256-HzzZFiY4t0PIv02Tm8/R3CVvLpcjHhO1z/YAUCp4oQ4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-url/2.3.0/url.min.js" integrity="sha256-HOZJz4x+1mn1Si84WT5XKXPtOlTytmZLnMb6n1v4+5Q=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/1000hz-bootstrap-validator/0.11.8/validator.min.js" integrity="sha256-LHeY7YoYJ0SSXbCx7sR14Pqna+52moaH3bhv0Mjzd/M=" crossorigin="anonymous" defer></script>
<%- include ../build/cover-scripts %>

View File

@ -11,10 +11,10 @@
<link rel="apple-touch-icon" href="<%- serverURL %>/apple-touch-icon.png">
<% if(useCDN) { %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.4.0/css/bootstrap.min.css" integrity="sha256-H0KfTigpUV+0/5tn2HXC0CPwhhDhWgSawJdnFd0CGCo=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fork-awesome/1.1.3/css/fork-awesome.min.css" integrity="sha256-ZhApazu+kejqTYhMF+1DzNKjIzP7KXu6AzyXcC1gMus=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fork-awesome/1.1.7/css/fork-awesome.min.css" integrity="sha256-gsmEoJAws/Kd3CjuOQzLie5Q3yshhvmo7YNtBG7aaEY=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-social/4.9.0/bootstrap-social.min.css" integrity="sha256-02JtFTurpwBjQJ6q13iJe82/NF0RbZlJroDegK5g87Y=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/3.5.4/select2.min.css" integrity="sha256-ijlUKKj3hJCiiT2HWo1kqkI79NTEYpzOsw5Rs3k42dI=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/3.5.4/select2-bootstrap.min.css" integrity="sha256-NAWFcNIZdH+TS1xpWujF/EB/Y8gwBbEOCoaK/eqaer8=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/3.5.2/select2.min.css" integrity="sha256-k5tPXFBQl+dOk8OmqCtptRa7bRYNRJuvs37bcqsmDB0=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/3.5.2/select2-bootstrap.min.css" integrity="sha256-HbewCP50syA/2d3zPv+/CdQ4ufX6bI2ntjD3MwsA0UE=" crossorigin="anonymous" />
<%- include ../build/cover-header %>
<%- include ../shared/polyfill %>
<% } else { %>

View File

@ -73,22 +73,26 @@
</html>
<script src="<%= serverURL %>/js/mathjax-config-extra.js"></script>
<% if(useCDN) { %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.4.0/velocity.min.js" integrity="sha256-bhm0lgEt6ITaZCDzZpkr/VXVrLa5RP4u9v2AYsbzSUk=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.5.2/velocity.min.js" integrity="sha256-1HqoI76JGKA17K0C0s9K8L/iy8PAC43KVLt1hRD/Ojc=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.4.0/js/bootstrap.min.js" integrity="sha256-kJrlY+s09+QoWjpkOrXXwhxeaoDz9FW5SaxF8I0DibQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-mousewheel/3.1.13/jquery.mousewheel.min.js" integrity="sha256-jnOjDTXIPqall8M0MyTSt98JetJuZ7Yu+1Jm7hLTF7U=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/Safe.js" integrity="sha256-0ygBUDksNDXZS4vm5HMNH1a33KUu6QT1cdNTN+ZLF+4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.0/mermaid.min.js" integrity="sha256-M3OC0Q6g4/+Q4j73OvnsnA+lMkdAE5KgupRHqTiPbnI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.2.3/mermaid.min.js" integrity="sha256-4s3fF5e1iWRLtiV7mRev7n17oALqqDHbWrNqF3/r7jU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.2/handlebars.min.js" integrity="sha256-ngJY93C4H39YbmrWhnLzSyiepRuQDVKDNCWO2iyMzFw=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/highlight.min.js" integrity="sha256-1zu+3BnLYV9LdiY85uXMzii3bdrkelyp37e0ZyTAQh0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gist-embed/2.6.0/gist-embed.min.js" integrity="sha256-KyF2D6xPIJUW5sUDSs93vWyZm+1RzIpKCexxElmxl8g=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.7.0/viz.js" integrity="sha256-8t+rndrF+TU4JtelmOH1lDHTMe2ovhO2UbzDArp5lY8=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/abcjs/3.1.1/abcjs_basic-min.js" integrity="sha256-Sq1r2XXWXQoShQKsS0Wrf5r7fRkErd9Fat9vHYeU68s=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/2.1.2/viz.js" integrity="sha256-8RHyK+AFzq9iXwbFo2unqidwPbwHU5FFWe3RwkcVtuU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/2.1.2/full.render.js" integrity="sha256-Ogqs510LFnekr9o7OLdpelaaAmNss9egQRTyzCqV2NQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/abcjs/3.1.1/abcjs_basic-min.js" integrity="sha256-Sq1r2XXWXQoShQKsS0Wrf5r7fRkErd9Fat9vHYeU68s=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega/5.4.0/vega.min.js" integrity="sha256-PrkRj4B3I5V9yHBLdO3jyyqNUwSKS1CXXIh3VrnFPEU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-lite/3.4.0/vega-lite.min.js" integrity="sha256-ro+FWr16NboXJ5rSwInNli1P16ObUXnWUJMgKc8KnHI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-embed/4.2.2/vega-embed.min.js" integrity="sha256-FvIpRBgWEczIjFNpbshtVJbx3QlxqxkBkf+xqZeOxUU=" crossorigin="anonymous" defer></script>
<%- include build/pretty-scripts %>
<% } else { %>
<script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script>

View File

@ -16,7 +16,7 @@
<link rel="apple-touch-icon" href="<%- serverURL %>/apple-touch-icon.png">
<% if(useCDN) { %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fork-awesome/1.1.3/css/fork-awesome.min.css" integrity="sha256-ZhApazu+kejqTYhMF+1DzNKjIzP7KXu6AzyXcC1gMus=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/fork-awesome/1.1.7/css/fork-awesome.min.css" integrity="sha256-gsmEoJAws/Kd3CjuOQzLie5Q3yshhvmo7YNtBG7aaEY=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/ionicons/2.0.1/css/ionicons.min.css" integrity="sha256-3iu9jgsy9TpTwXKb7bNQzqWekRX7pPK+2OLj3R922fo=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/3.7.0/css/reveal.min.css" integrity="sha256-9+Wg2bcNeiOMGXOUNqBdceY2lAH/eCiTDcdzHhHIl48=" crossorigin="anonymous" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/css/basic/emojify.min.css" integrity="sha256-UOrvMOsSDSrW6szVLe8ZDZezBxh5IoIfgTwdNDgTjiU=" crossorigin="anonymous" />
@ -90,21 +90,25 @@
<% if(useCDN) { %>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/3.7.0/lib/js/head.min.js" integrity="sha256-CTcwyen1cxIrm4hlqdxe0y7Hq6B0rpxAKLiXMD3dJv0=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/3.7.0/js/reveal.min.js" integrity="sha256-Xr6ZH+/kc7hDVReZLO5khBknteLqu5oen/xnSraXrVk=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.1.1/jquery.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.4.0/velocity.min.js" integrity="sha256-bhm0lgEt6ITaZCDzZpkr/VXVrLa5RP4u9v2AYsbzSUk=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js" integrity="sha256-CSXorXvZcTkaix6Yvo6HppcZGetbYMGWSFlBw8HfCJo=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/velocity/1.5.2/velocity.min.js" integrity="sha256-1HqoI76JGKA17K0C0s9K8L/iy8PAC43KVLt1hRD/Ojc=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-mousewheel/3.1.13/jquery.mousewheel.min.js" integrity="sha256-jnOjDTXIPqall8M0MyTSt98JetJuZ7Yu+1Jm7hLTF7U=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.7.0/js-yaml.min.js" integrity="sha256-8PanqYAVOGlOct+i65R+HqibK3KPsXINnrSfxN+Y/J0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.13.1/js-yaml.min.js" integrity="sha256-ry6nlLQ1JmRl5RUPQ4eSuaSp/rGNPvl144WHHs7BiNE=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/MathJax.js" integrity="sha256-yYfngbEKv4RENfGDvNUqJTqGFcKf31NJEe9OTnnMH3Y=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/TeX-AMS-MML_HTMLorMML.js" integrity="sha256-immzXfCGLhnx3Zfi9F/dUcqxEM8K3o3oTFy9Bh6HCwg=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.0/config/Safe.js" integrity="sha256-0ygBUDksNDXZS4vm5HMNH1a33KUu6QT1cdNTN+ZLF+4=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.17.1/moment-with-locales.min.js" integrity="sha256-vvT7Ok9u6GbfnBPXnbM6FVDEO8E1kTdgHOFZOAXrktA=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/7.1.0/mermaid.min.js" integrity="sha256-M3OC0Q6g4/+Q4j73OvnsnA+lMkdAE5KgupRHqTiPbnI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment-with-locales.min.js" integrity="sha256-AdQN98MVZs44Eq2yTwtoKufhnU+uZ7v2kXnD5vqzZVo=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/8.2.3/mermaid.min.js" integrity="sha256-4s3fF5e1iWRLtiV7mRev7n17oALqqDHbWrNqF3/r7jU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/emojify.js/1.1.0/js/emojify.min.js" integrity="sha256-VAB5tAlKBvgaxw8oJ1crWMVbdmBVl4mP/2M8MNRl+4E=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.6/handlebars.min.js" integrity="sha256-1O3BtOwnPyyRzOszK6P+gqaRoXHV6JXj8HkjZmPYhCI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js" integrity="sha256-/BfiIkHlHoVihZdc6TFuj7MmJ0TWcWsMXkeDFwhi0zw=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.2/handlebars.min.js" integrity="sha256-ngJY93C4H39YbmrWhnLzSyiepRuQDVKDNCWO2iyMzFw=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.10/highlight.min.js" integrity="sha256-1zu+3BnLYV9LdiY85uXMzii3bdrkelyp37e0ZyTAQh0=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gist-embed/2.6.0/gist-embed.min.js" integrity="sha256-KyF2D6xPIJUW5sUDSs93vWyZm+1RzIpKCexxElmxl8g=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.7.0/viz.js" integrity="sha256-8t+rndrF+TU4JtelmOH1lDHTMe2ovhO2UbzDArp5lY8=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/abcjs/3.1.1/abcjs_basic-min.js" integrity="sha256-Sq1r2XXWXQoShQKsS0Wrf5r7fRkErd9Fat9vHYeU68s=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/2.1.2/viz.js" integrity="sha256-8RHyK+AFzq9iXwbFo2unqidwPbwHU5FFWe3RwkcVtuU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/2.1.2/full.render.js" integrity="sha256-Ogqs510LFnekr9o7OLdpelaaAmNss9egQRTyzCqV2NQ=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/abcjs/3.1.1/abcjs_basic-min.js" integrity="sha256-Sq1r2XXWXQoShQKsS0Wrf5r7fRkErd9Fat9vHYeU68s=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega/5.4.0/vega.min.js" integrity="sha256-PrkRj4B3I5V9yHBLdO3jyyqNUwSKS1CXXIh3VrnFPEU=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-lite/3.4.0/vega-lite.min.js" integrity="sha256-ro+FWr16NboXJ5rSwInNli1P16ObUXnWUJMgKc8KnHI=" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vega-embed/4.2.2/vega-embed.min.js" integrity="sha256-FvIpRBgWEczIjFNpbshtVJbx3QlxqxkBkf+xqZeOxUU=" crossorigin="anonymous" defer></script>
<%- include build/slide-scripts %>
<% } else { %>
<script src="<%- serverURL %>/build/MathJax/MathJax.js" defer></script>

View File

@ -0,0 +1,129 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const sinon = require('sinon')
const { ProcessQueue } = require('../lib/processQueue')
describe('ProcessQueue', function () {
let clock
const waitTimeForCheckResult = 50
beforeEach(() => {
clock = sinon.useFakeTimers({
toFake: ['setInterval']
})
})
afterEach(() => {
clock.restore()
sinon.restore()
})
it('should not accept more than maximum task', () => {
const queue = new ProcessQueue({ maximumLength: 2 })
queue.start()
assert(queue.push(1, () => (Promise.resolve())))
assert(queue.push(1, () => (Promise.resolve())) === false)
})
it('should run task every interval', (done) => {
const runningClock = []
const queue = new ProcessQueue({ maximumLength: 2 })
const task = async () => {
runningClock.push(clock.now)
}
queue.start()
assert(queue.push(1, task))
assert(queue.push(2, task))
clock.tick(5)
setTimeout(() => {
clock.tick(5)
}, 1)
setTimeout(() => {
clock.tick(5)
}, 2)
setTimeout(() => {
clock.tick(5)
}, 3)
setTimeout(() => {
queue.stop()
assert(runningClock.length === 2)
done()
}, waitTimeForCheckResult)
})
it('should not crash when repeat stop queue', () => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
try {
queue.stop()
queue.stop()
queue.stop()
assert.ok(true)
} catch (e) {
assert.fail(e)
}
})
it('should run process when queue is empty', (done) => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
const processSpy = sinon.spy(queue, 'process')
queue.start()
clock.tick(100)
setTimeout(() => {
assert(processSpy.called)
done()
}, waitTimeForCheckResult)
})
it('should run process although error occurred', (done) => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 100 })
const failedTask = sinon.spy(async () => {
throw new Error('error')
})
const normalTask = sinon.spy(async () => {
})
queue.start()
assert(queue.push(1, failedTask))
assert(queue.push(2, normalTask))
clock.tick(100)
setTimeout(() => {
clock.tick(100)
}, 1)
setTimeout(() => {
// assert(queue.queue.length === 0)
assert(failedTask.called)
assert(normalTask.called)
done()
}, waitTimeForCheckResult)
})
it('should ignore trigger when event not complete', (done) => {
const queue = new ProcessQueue({ maximumLength: 2, triggerTimeInterval: 10 })
const processSpy = sinon.spy(queue, 'process')
const longTask = async () => {
return new Promise((resolve) => {
setInterval(() => {
resolve()
}, 50)
})
}
queue.start()
queue.push(1, longTask)
clock.tick(10)
setTimeout(() => {
clock.tick(10)
}, 0)
setTimeout(() => {
clock.tick(10)
}, 1)
setTimeout(() => {
assert(processSpy.callCount === 1)
assert(processSpy.calledOnce)
done()
}, waitTimeForCheckResult)
})
})

View File

@ -43,7 +43,7 @@ describe('Content security policies', function () {
// beginnging Tests
it('Disable CDN', function () {
let testconfig = defaultConfig
const testconfig = defaultConfig
testconfig.useCDN = false
mock('../lib/config', testconfig)
csp = mock.reRequire('../lib/csp')
@ -57,7 +57,7 @@ describe('Content security policies', function () {
})
it('Disable Google Analytics', function () {
let testconfig = defaultConfig
const testconfig = defaultConfig
testconfig.csp.addGoogleAnalytics = false
mock('../lib/config', testconfig)
csp = mock.reRequire('../lib/csp')
@ -66,7 +66,7 @@ describe('Content security policies', function () {
})
it('Disable Disqus', function () {
let testconfig = defaultConfig
const testconfig = defaultConfig
testconfig.csp.addDisqus = false
mock('../lib/config', testconfig)
csp = mock.reRequire('../lib/csp')
@ -79,7 +79,7 @@ describe('Content security policies', function () {
})
it('Set ReportURI', function () {
let testconfig = defaultConfig
const testconfig = defaultConfig
testconfig.csp.reportURI = 'https://example.com/reportURI'
mock('../lib/config', testconfig)
csp = mock.reRequire('../lib/csp')
@ -88,7 +88,7 @@ describe('Content security policies', function () {
})
it('Set own directives', function () {
let testconfig = defaultConfig
const testconfig = defaultConfig
mock('../lib/config', defaultConfig)
csp = mock.reRequire('../lib/csp')
const unextendedCSP = csp.computeDirectives()

View File

@ -9,7 +9,7 @@ describe('generateAvatarURL() gravatar enabled', function () {
let avatars
beforeEach(function () {
// Reset config to make sure we don't influence other tests
let testconfig = {
const testconfig = {
allowGravatar: true,
serverURL: 'http://localhost:3000',
port: 3000
@ -32,7 +32,7 @@ describe('generateAvatarURL() gravatar disabled', function () {
let avatars
beforeEach(function () {
// Reset config to make sure we don't influence other tests
let testconfig = {
const testconfig = {
allowGravatar: false,
serverURL: 'http://localhost:3000',
port: 3000

View File

@ -0,0 +1,69 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { removeModuleFromRequireCache, makeMockSocket } = require('./utils')
describe('cleanDanglingUser', function () {
let clock
beforeEach(() => {
clock = sinon.useFakeTimers()
mock('../../lib/processQueue', require('../testDoubles/ProcessQueueFake'))
mock('../../lib/logger', {
error: () => {},
info: () => {}
})
mock('../../lib/history', {})
mock('../../lib/models', {
Revision: {
saveAllNotesRevision: () => {
}
}
})
mock('../../lib/config', {
debug: true
})
mock('../../lib/realtimeUpdateDirtyNoteJob', require('../testDoubles/realtimeJobStub'))
mock('../../lib/realtimeSaveRevisionJob', require('../testDoubles/realtimeJobStub'))
})
afterEach(() => {
clock.restore()
removeModuleFromRequireCache('../../lib/realtime')
mock.stopAll()
sinon.restore()
})
it('should call queueForDisconnectSpy when user is dangling', (done) => {
const realtime = require('../../lib/realtime')
const queueForDisconnectSpy = sinon.spy(realtime, 'queueForDisconnect')
realtime.io = {
to: sinon.stub().callsFake(function () {
return {
emit: sinon.fake()
}
}),
sockets: {
connected: {}
}
}
const user1Socket = makeMockSocket()
const user2Socket = makeMockSocket()
user1Socket.rooms.push('room1')
realtime.io.sockets.connected[user1Socket.id] = user1Socket
realtime.io.sockets.connected[user2Socket.id] = user2Socket
realtime.users[user1Socket.id] = user1Socket
realtime.users[user2Socket.id] = user2Socket
clock.tick(60000)
clock.restore()
setTimeout(() => {
assert(queueForDisconnectSpy.called)
done()
}, 50)
})
})

View File

@ -0,0 +1,193 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { createFakeLogger } = require('../testDoubles/loggerFake')
const { removeLibModuleCache, makeMockSocket } = require('./utils')
const realtimeJobStub = require('../testDoubles/realtimeJobStub')
describe('realtime#connection', function () {
describe('connection', function () {
let realtime
let modelStub
beforeEach(() => {
removeLibModuleCache()
modelStub = {
Note: {
findOne: sinon.stub()
},
User: {},
Author: {}
}
mock('../../lib/logger', createFakeLogger())
mock('../../lib/history', {})
mock('../../lib/models', modelStub)
mock('../../lib/config', {})
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
mock('../../lib/ot', require('../testDoubles/otFake'))
realtime = require('../../lib/realtime')
})
afterEach(() => {
mock.stopAll()
sinon.restore()
})
describe('fail', function () {
it('should fast return when server not start', () => {
const mockSocket = makeMockSocket()
realtime.maintenance = true
const spy = sinon.spy(realtime, 'parseNoteIdFromSocketAsync')
realtime.connection(mockSocket)
assert(!spy.called)
})
it('should failed when parse noteId occur error', (done) => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
/* eslint-disable-next-line */
throw 'error'
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
setTimeout(() => {
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [500, 'error', mockSocket])
done()
}, 50)
})
it('should failed when noteId not exists', (done) => {
const mockSocket = makeMockSocket()
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return null
})
const failConnectionSpy = sinon.stub(realtime, 'failConnection')
realtime.connection(mockSocket)
setTimeout(() => {
assert(parseNoteIdFromSocketSpy.called)
assert(failConnectionSpy.calledOnce)
assert.deepStrictEqual(failConnectionSpy.lastCall.args, [404, 'note id not found', mockSocket])
done()
}, 50)
})
})
it('should success connect', function (done) {
const mockSocket = makeMockSocket()
const noteId = 'note123'
realtime.maintenance = false
const parseNoteIdFromSocketSpy = sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return noteId
})
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
realtime.connection(mockSocket)
setTimeout(() => {
assert.ok(parseNoteIdFromSocketSpy.calledOnce)
assert(updateUserDataStub.calledOnce)
done()
}, 50)
})
describe('flow', function () {
it('should establish connection', function (done) {
const noteId = 'note123'
const mockSocket = makeMockSocket(null, {
noteId: noteId
})
mockSocket.request.user.logged_in = true
mockSocket.request.user.id = 'user1'
mockSocket.noteId = noteId
realtime.maintenance = false
sinon.stub(realtime, 'parseNoteIdFromSocketAsync').callsFake(async (socket) => {
return noteId
})
const updateHistoryStub = sinon.stub(realtime, 'updateHistory')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
const emitRefreshStub = sinon.stub(realtime, 'emitRefresh')
const failConnectionSpy = sinon.spy(realtime, 'failConnection')
const note = {
id: noteId,
authors: [
{
userId: 'user1',
color: 'red',
user: {
id: 'user1',
name: 'Alice'
}
},
{
userId: 'user2',
color: 'blue',
user: {
id: 'user2',
name: 'Bob'
}
}
]
}
modelStub.Note.findOne.returns(Promise.resolve(note))
modelStub.User.getProfile = sinon.stub().callsFake((user) => {
return user
})
sinon.stub(realtime, 'checkViewPermission').returns(true)
realtime.connection(mockSocket)
setTimeout(() => {
assert(modelStub.Note.findOne.calledOnce)
assert.deepStrictEqual(modelStub.Note.findOne.lastCall.args[0].include, [
{
model: modelStub.User,
as: 'owner'
}, {
model: modelStub.User,
as: 'lastchangeuser'
}, {
model: modelStub.Author,
as: 'authors',
include: [{
model: modelStub.User,
as: 'user'
}]
}
])
assert(modelStub.Note.findOne.lastCall.args[0].where.id === noteId)
assert(updateHistoryStub.calledOnce)
assert(emitOnlineUsersStub.calledOnce)
assert(emitRefreshStub.calledOnce)
assert(failConnectionSpy.callCount === 0)
assert(realtime.getNotePool()[noteId].id === noteId)
assert(realtime.getNotePool()[noteId].socks.length === 1)
assert.deepStrictEqual(realtime.getNotePool()[noteId].authors, {
user1: {
userid: 'user1', color: 'red', photo: undefined, name: 'Alice'
},
user2: {
userid: 'user2', color: 'blue', photo: undefined, name: 'Bob'
}
})
assert(Object.keys(realtime.getNotePool()[noteId].users).length === 1)
done()
}, 50)
})
})
})
})

View File

@ -0,0 +1,129 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { removeModuleFromRequireCache, makeMockSocket, removeLibModuleCache } = require('./utils')
describe('realtime#update note is dirty timer', function () {
let realtime
let clock
beforeEach(() => {
removeLibModuleCache()
clock = sinon.useFakeTimers({
toFake: ['setInterval']
})
mock('../../lib/logger', {
error: () => {
}
})
mock('../../lib/history', {})
mock('../../lib/models', {
Revision: {
saveAllNotesRevision: () => {
}
}
})
mock('../../lib/config', {})
realtime = require('../../lib/realtime')
realtime.io = {
to: sinon.stub().callsFake(function () {
return {
emit: sinon.fake()
}
})
}
})
afterEach(() => {
removeModuleFromRequireCache('../../lib/realtimeUpdateDirtyNoteJob')
removeModuleFromRequireCache('../../lib/realtime')
mock.stopAll()
clock.restore()
})
it('should update note when note is dirty', (done) => {
sinon.stub(realtime, 'updateNote').callsFake(function (note, callback) {
callback(null, note)
})
realtime.notes['note1'] = {
server: {
isDirty: false
},
socks: []
}
const note2 = {
server: {
isDirty: true
},
socks: []
}
realtime.notes['note2'] = note2
clock.tick(1000)
setTimeout(() => {
assert(note2.server.isDirty === false)
done()
}, 10)
})
it('should not do anything when note missing', function (done) {
sinon.stub(realtime, 'updateNote').callsFake(function (note, callback) {
delete realtime.notes['note']
callback(null, note)
})
const note = {
server: {
isDirty: true
},
socks: [makeMockSocket()]
}
realtime.notes['note'] = note
clock.tick(1000)
setTimeout(() => {
assert(note.server.isDirty === false)
assert(note.socks[0].disconnect.called === false)
done()
}, 50)
})
it('should disconnect all clients when update note error', function (done) {
sinon.stub(realtime, 'updateNote').callsFake(function (note, callback) {
callback(new Error('some error'), null)
})
realtime.io = {
to: sinon.stub().callsFake(function () {
return {
emit: sinon.fake()
}
})
}
const note = {
server: {
isDirty: true
},
socks: [makeMockSocket(), undefined, makeMockSocket()]
}
realtime.notes['note'] = note
clock.tick(1000)
setTimeout(() => {
assert(note.server.isDirty === false)
assert(note.socks[0].disconnect.called)
assert(note.socks[2].disconnect.called)
done()
}, 50)
})
})

View File

@ -0,0 +1,94 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { makeMockSocket, removeModuleFromRequireCache } = require('./utils')
describe('realtime#disconnect', function () {
const noteId = 'note1_id'
let realtime
let updateNoteStub
let emitOnlineUsersStub
let client
beforeEach(() => {
mock('../../lib/logger', {
error: () => {
}
})
mock('../../lib/history', {})
mock('../../lib/models', {
Revision: {
saveAllNotesRevision: () => {
}
}
})
mock('../../lib/config', {})
realtime = require('../../lib/realtime')
updateNoteStub = sinon.stub(realtime, 'updateNote').callsFake((note, callback) => {
callback(null, note)
})
emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
client = makeMockSocket()
client.noteId = noteId
realtime.users[client.id] = {
id: client.id,
color: '#ff0000',
cursor: null,
login: false,
userid: null,
name: null,
idle: false,
type: null
}
realtime.getNotePool()[noteId] = {
id: noteId,
server: {
isDirty: true
},
users: {
[client.id]: realtime.users[client.id]
},
socks: [client]
}
})
afterEach(() => {
removeModuleFromRequireCache('../../lib/realtime')
mock.stopAll()
sinon.restore()
})
it('should disconnect success', function (done) {
realtime.queueForDisconnect(client)
setTimeout(() => {
assert(typeof realtime.users[client.id] === 'undefined')
assert(emitOnlineUsersStub.called)
assert(updateNoteStub.called)
assert(Object.keys(realtime.users).length === 0)
assert(Object.keys(realtime.notes).length === 0)
done()
}, 5)
})
it('should disconnect success when note is not dirty', function (done) {
realtime.notes[noteId].server.isDirty = false
realtime.queueForDisconnect(client)
setTimeout(() => {
assert(typeof realtime.users[client.id] === 'undefined')
assert(emitOnlineUsersStub.called)
assert(updateNoteStub.called === false)
assert(Object.keys(realtime.users).length === 0)
assert(Object.keys(realtime.notes).length === 0)
done()
}, 5)
})
})

View File

@ -0,0 +1,91 @@
/* eslint-env node, mocha */
'use strict'
const mock = require('mock-require')
const assert = require('assert')
const { makeMockSocket } = require('./utils')
describe('realtime#extractNoteIdFromSocket', function () {
beforeEach(() => {
mock('../../lib/logger', {})
mock('../../lib/history', {})
mock('../../lib/models', {})
})
afterEach(() => {
delete require.cache[require.resolve('../../lib/realtime')]
mock.stopAll()
})
describe('urlPath not set', function () {
beforeEach(() => {
mock('../../lib/config', {})
realtime = require('../../lib/realtime')
})
let realtime
it('return false if socket or socket.handshake not exists', function () {
let noteId = realtime.extractNoteIdFromSocket()
assert.strictEqual(false, noteId)
noteId = realtime.extractNoteIdFromSocket({})
assert.strictEqual(false, noteId)
})
it('return false if query not set and referer not set', function () {
const noteId = realtime.extractNoteIdFromSocket(makeMockSocket({
otherHeader: 1
}, {
otherQuery: 1
}))
assert.strictEqual(false, noteId)
})
it('return noteId from query', function () {
// Arrange
const incomingNoteId = 'myNoteId'
const incomingSocket = makeMockSocket(undefined, { noteId: incomingNoteId })
// Act
const noteId = realtime.extractNoteIdFromSocket(incomingSocket)
// Assert
assert.strictEqual(noteId, incomingNoteId)
})
it('return noteId from old method (referer)', function () {
// Arrange
const incomingNoteId = 'myNoteId'
const incomingSocket = makeMockSocket({
referer: `https://localhost:3000/${incomingNoteId}`
})
// Act
const noteId = realtime.extractNoteIdFromSocket(incomingSocket)
// Assert
assert.strictEqual(noteId, incomingNoteId)
})
})
describe('urlPath is set', function () {
let realtime
it('return noteId from old method (referer) and urlPath set', function () {
// Arrange
const urlPath = 'hello'
mock('../../lib/config', {
urlPath: urlPath
})
realtime = require('../../lib/realtime')
const incomingNoteId = 'myNoteId'
const incomingSocket = makeMockSocket({
referer: `https://localhost:3000/${urlPath}/${incomingNoteId}`
})
// Act
const noteId = realtime.extractNoteIdFromSocket(incomingSocket)
// Assert
assert.strictEqual(noteId, incomingNoteId)
})
})
})

View File

@ -0,0 +1,126 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { createFakeLogger } = require('../testDoubles/loggerFake')
const realtimeJobStub = require('../testDoubles/realtimeJobStub')
const { removeLibModuleCache, makeMockSocket } = require('./utils')
describe('realtime#ifMayEdit', function () {
let modelsStub
beforeEach(() => {
removeLibModuleCache()
mock('../../lib/config', {})
mock('../../lib/logger', createFakeLogger())
mock('../../lib/models', modelsStub)
mock('../../lib/realtimeUpdateDirtyNoteJob', realtimeJobStub)
mock('../../lib/realtimeCleanDanglingUserJob', realtimeJobStub)
mock('../../lib/realtimeSaveRevisionJob', realtimeJobStub)
})
afterEach(() => {
mock.stopAll()
sinon.restore()
})
const Role = {
Guest: 'guest',
LoggedIn: 'LoggedIn',
Owner: 'Owner'
}
const Permission = {
Freely: 'freely',
Editable: 'editable',
Limited: 'limited',
Locked: 'locked',
Protected: 'protected',
Private: 'private'
}
const testcases = [
{ role: Role.Guest, permission: Permission.Freely, canEdit: true },
{ role: Role.LoggedIn, permission: Permission.Freely, canEdit: true },
{ role: Role.Owner, permission: Permission.Freely, canEdit: true },
{ role: Role.Guest, permission: Permission.Editable, canEdit: false },
{ role: Role.LoggedIn, permission: Permission.Editable, canEdit: true },
{ role: Role.Owner, permission: Permission.Editable, canEdit: true },
{ role: Role.Guest, permission: Permission.Limited, canEdit: false },
{ role: Role.LoggedIn, permission: Permission.Limited, canEdit: true },
{ role: Role.Owner, permission: Permission.Limited, canEdit: true },
{ role: Role.Guest, permission: Permission.Locked, canEdit: false },
{ role: Role.LoggedIn, permission: Permission.Locked, canEdit: false },
{ role: Role.Owner, permission: Permission.Locked, canEdit: true },
{ role: Role.Guest, permission: Permission.Protected, canEdit: false },
{ role: Role.LoggedIn, permission: Permission.Protected, canEdit: false },
{ role: Role.Owner, permission: Permission.Protected, canEdit: true },
{ role: Role.Guest, permission: Permission.Private, canEdit: false },
{ role: Role.LoggedIn, permission: Permission.Private, canEdit: false },
{ role: Role.Owner, permission: Permission.Private, canEdit: true }
]
const noteOwnerId = 'owner'
const loggedInUserId = 'user1'
const noteId = 'noteId'
testcases.forEach((tc) => {
it(`${tc.role} ${tc.canEdit ? 'can' : 'can\'t'} edit note with permission ${tc.permission}`, function () {
const client = makeMockSocket()
const note = {
permission: tc.permission,
owner: noteOwnerId
}
if (tc.role === Role.LoggedIn) {
client.request.user.logged_in = true
client.request.user.id = loggedInUserId
} else if (tc.role === Role.Owner) {
client.request.user.logged_in = true
client.request.user.id = noteOwnerId
}
client.noteId = noteId
const realtime = require('../../lib/realtime')
realtime.getNotePool()[noteId] = note
const callback = sinon.stub()
realtime.ifMayEdit(client, callback)
assert(callback.calledOnce)
assert(callback.lastCall.args[0] === tc.canEdit)
})
})
it('should set lsatchangeuser to null if guest edit operation', function () {
const note = {
permission: Permission.Freely
}
const client = makeMockSocket()
client.noteId = noteId
const callback = sinon.stub()
client.origin = 'operation'
const realtime = require('../../lib/realtime')
realtime.getNotePool()[noteId] = note
realtime.ifMayEdit(client, callback)
assert(callback.calledOnce)
assert(callback.lastCall.args[0])
assert(note.lastchangeuser === null)
})
it('should set lastchangeuser to logged_in user id if user edit', function () {
const note = {
permission: Permission.Freely
}
const client = makeMockSocket()
client.noteId = noteId
client.request.user.logged_in = true
client.request.user.id = loggedInUserId
const callback = sinon.stub()
client.origin = 'operation'
const realtime = require('../../lib/realtime')
realtime.getNotePool()[noteId] = note
realtime.ifMayEdit(client, callback)
assert(callback.calledOnce)
assert(callback.lastCall.args[0])
assert(note.lastchangeuser === loggedInUserId)
})
})

View File

@ -0,0 +1,115 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const { makeMockSocket, removeModuleFromRequireCache } = require('./utils')
describe('realtime#parseNoteIdFromSocketAsync', function () {
let realtime
beforeEach(() => {
mock('../../lib/logger', {})
mock('../../lib/history', {})
mock('../../lib/models', {
Note: {
parseNoteId: function (noteId, callback) {
callback(null, noteId)
}
}
})
mock('../../lib/config', {})
})
afterEach(() => {
removeModuleFromRequireCache('../../lib/realtime')
mock.stopAll()
})
it('should return null when socket not send noteId', async function () {
realtime = require('../../lib/realtime')
const mockSocket = makeMockSocket()
try {
const notes = await realtime.parseNoteIdFromSocketAsync(mockSocket)
assert(notes === null)
} catch (err) {
assert.fail('should not occur any error')
}
})
describe('noteId exists', function () {
beforeEach(() => {
mock('../../lib/models', {
Note: {
parseNoteId: function (noteId, callback) {
callback(null, noteId)
}
}
})
})
it('should return noteId when noteId exists', async function () {
realtime = require('../../lib/realtime')
const noteId = '123456'
const mockSocket = makeMockSocket(undefined, {
noteId: noteId
})
realtime = require('../../lib/realtime')
let parsedNoteId
try {
parsedNoteId = await realtime.parseNoteIdFromSocketAsync(mockSocket)
} catch (err) {
assert.fail(`should not occur any error ${err} `)
}
assert(parsedNoteId === noteId)
})
})
describe('noteId not exists', function () {
beforeEach(() => {
mock('../../lib/models', {
Note: {
parseNoteId: function (noteId, callback) {
callback(null, null)
}
}
})
})
it('should return null when noteId not exists', async function () {
realtime = require('../../lib/realtime')
const noteId = '123456'
const mockSocket = makeMockSocket(undefined, {
noteId: noteId
})
realtime = require('../../lib/realtime')
const parsedNoteId = await realtime.parseNoteIdFromSocketAsync(mockSocket)
assert(parsedNoteId === null)
})
})
describe('parse note error', function () {
beforeEach(() => {
mock('../../lib/models', {
Note: {
parseNoteId: function (noteId, callback) {
/* eslint-disable-next-line */
callback('error', null)
}
}
})
})
it('should return error when noteId parse error', async function () {
realtime = require('../../lib/realtime')
const noteId = '123456'
const mockSocket = makeMockSocket(undefined, {
noteId: noteId
})
realtime = require('../../lib/realtime')
try {
await realtime.parseNoteIdFromSocketAsync(mockSocket)
} catch (err) {
assert(err === 'error')
}
})
})
})

View File

@ -0,0 +1,73 @@
'use strict'
/* eslint-env node, mocha */
const mock = require('mock-require')
const assert = require('assert')
describe('realtime', function () {
describe('checkViewPermission', function () {
// role -> guest, loggedInUser, loggedInOwner
const viewPermission = {
freely: [true, true, true],
editable: [true, true, true],
limited: [false, true, true],
locked: [true, true, true],
protected: [false, true, true],
private: [false, false, true]
}
const loggedInUserId = 'user1_id'
const ownerUserId = 'user2_id'
const guestReq = {}
const loggedInUserReq = {
user: {
id: loggedInUserId,
logged_in: true
}
}
const loggedInOwnerReq = {
user: {
id: ownerUserId,
logged_in: true
}
}
const note = {
owner: ownerUserId
}
let realtime
beforeEach(() => {
mock('../../lib/logger', {
error: () => {
}
})
mock('../../lib/history', {})
mock('../../lib/models', {
Note: {
parseNoteTitle: (data) => (data)
}
})
mock('../../lib/config', {})
realtime = require('../../lib/realtime')
})
Object.keys(viewPermission).forEach(function (permission) {
describe(permission, function () {
beforeEach(() => {
note.permission = permission
})
it('guest permission test', function () {
assert(realtime.checkViewPermission(guestReq, note) === viewPermission[permission][0])
})
it('loggedIn User permission test', function () {
assert(realtime.checkViewPermission(loggedInUserReq, note) === viewPermission[permission][1])
})
it('loggedIn Owner permission test', function () {
assert(realtime.checkViewPermission(loggedInOwnerReq, note) === viewPermission[permission][2])
})
})
})
})
})

View File

@ -0,0 +1,70 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { removeModuleFromRequireCache, removeLibModuleCache } = require('./utils')
describe('save revision job', function () {
let clock
let mockModels
let realtime
beforeEach(() => {
removeLibModuleCache()
mockModels = {
Revision: {
saveAllNotesRevision: sinon.stub()
}
}
clock = sinon.useFakeTimers()
mock('../../lib/processQueue', require('../testDoubles/ProcessQueueFake'))
mock('../../lib/logger', {
error: () => {},
info: () => {}
})
mock('../../lib/history', {})
mock('../../lib/models', mockModels)
mock('../../lib/config', {
debug: true
})
mock('../../lib/realtimeUpdateDirtyNoteJob', require('../testDoubles/realtimeJobStub'))
mock('../../lib/realtimeCleanDanglingUserJob', require('../testDoubles/realtimeJobStub'))
})
afterEach(() => {
clock.restore()
removeModuleFromRequireCache('../../lib/realtime')
removeModuleFromRequireCache('../../lib/realtimeSaveRevisionJob')
mock.stopAll()
sinon.restore()
})
it('should execute save revision job every 5 min', (done) => {
mockModels.Revision.saveAllNotesRevision.callsFake((callback) => {
callback(null, [])
})
realtime = require('../../lib/realtime')
clock.tick(5 * 60 * 1000)
clock.restore()
setTimeout(() => {
assert(mockModels.Revision.saveAllNotesRevision.called)
assert(realtime.saveRevisionJob.getSaverSleep() === true)
done()
}, 50)
})
it('should not set saverSleep when more than 1 note save revision', (done) => {
mockModels.Revision.saveAllNotesRevision.callsFake((callback) => {
callback(null, [1])
})
realtime = require('../../lib/realtime')
clock.tick(5 * 60 * 1000)
clock.restore()
setTimeout(() => {
assert(mockModels.Revision.saveAllNotesRevision.called)
assert(realtime.saveRevisionJob.getSaverSleep() === false)
done()
}, 50)
})
})

View File

@ -0,0 +1,668 @@
/* eslint-env node, mocha */
'use strict'
const assert = require('assert')
const mock = require('mock-require')
const sinon = require('sinon')
const { makeMockSocket, removeModuleFromRequireCache } = require('./utils')
describe('realtime#socket event', function () {
const noteId = 'note123'
const note = {
id: noteId,
authors: [
{
userId: 'user1',
color: 'red',
user: {
id: 'user1',
name: 'Alice'
}
},
{
userId: 'user2',
color: 'blue',
user: {
id: 'user2',
name: 'Bob'
}
}
]
}
let realtime
let clientSocket
let modelsMock
let eventFuncMap
let configMock
let clock
beforeEach(function (done) {
clock = sinon.useFakeTimers({
toFake: ['setInterval']
})
eventFuncMap = new Map()
modelsMock = {
Note: {
parseNoteTitle: (data) => (data),
destroy: sinon.stub().returns(Promise.resolve(1)),
update: sinon.stub().returns(Promise.resolve([1])),
findOne: sinon.stub().returns(Promise.resolve(note))
},
User: {}
}
modelsMock.User.getProfile = sinon.stub().callsFake((user) => {
return user
})
configMock = {
fullversion: '1.5.0',
minimumCompatibleVersion: '1.0.0',
permission: {
freely: 'freely',
editable: 'editable',
limited: 'limited',
locked: 'locked',
protected: 'protected',
private: 'private'
}
}
mock('../../lib/logger', {
error: () => {
},
info: () => {
}
})
mock('../../lib/history', {})
mock('../../lib/models', modelsMock)
mock('../../lib/config', configMock)
mock('../../lib/ot', require('../testDoubles/otFake'))
realtime = require('../../lib/realtime')
// get all socket event handler
clientSocket = makeMockSocket(null, {
noteId: noteId
})
clientSocket.request.user.logged_in = true
clientSocket.request.user.id = 'user1'
// clientSocket.noteId = noteId
clientSocket.on = function (event, func) {
eventFuncMap.set(event, func)
}
realtime.maintenance = false
realtime.io = (function () {
const roomMap = new Map()
return {
to: function (roomId) {
if (!roomMap.has(roomId)) {
roomMap.set(roomId, {
emit: sinon.stub()
})
}
return roomMap.get(roomId)
}
}
}())
const wrappedFuncs = []
wrappedFuncs.push(sinon.stub(realtime, 'updateUserData'))
wrappedFuncs.push(sinon.stub(realtime, 'emitOnlineUsers'))
wrappedFuncs.push(sinon.stub(realtime, 'parseNoteIdFromSocketAsync').returns(Promise.resolve(noteId)))
wrappedFuncs.push(sinon.stub(realtime, 'updateHistory'))
wrappedFuncs.push(sinon.stub(realtime, 'emitRefresh'))
realtime.connection(clientSocket)
setTimeout(() => {
wrappedFuncs.forEach((wrappedFunc) => {
wrappedFunc.restore()
})
done()
}, 50)
})
afterEach(function () {
removeModuleFromRequireCache('../../lib/realtime')
removeModuleFromRequireCache('../../lib/realtimeClientConnection')
mock.stopAll()
sinon.restore()
clock.restore()
clientSocket = null
})
describe('refresh', function () {
it('should call refresh', () => {
const refreshFunc = eventFuncMap.get('refresh')
const emitRefreshStub = sinon.stub(realtime, 'emitRefresh')
refreshFunc()
assert(emitRefreshStub.calledOnce)
assert.deepStrictEqual(emitRefreshStub.lastCall.args[0], clientSocket)
})
})
describe('user status', function () {
it('should call emitUserStatus and update user data', () => {
const userStatusFunc = eventFuncMap.get('user status')
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
realtime.notes[noteId] = {}
const userData = {
idle: true,
type: 'xs'
}
userStatusFunc(userData)
assert(emitUserStatusStub.calledOnce)
assert.deepStrictEqual(emitUserStatusStub.lastCall.args[0], clientSocket)
assert(realtime.users[clientSocket.id].idle === true)
assert(realtime.users[clientSocket.id].type === 'xs')
})
it('should call emitUserStatus without userdata', () => {
const userStatusFunc = eventFuncMap.get('user status')
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
realtime.notes[noteId] = {}
userStatusFunc()
assert(emitUserStatusStub.calledOnce)
assert.deepStrictEqual(emitUserStatusStub.lastCall.args[0], clientSocket)
assert(realtime.users[clientSocket.id].idle === false)
assert(realtime.users[clientSocket.id].type === null)
})
it('should not call emitUserStatus when user not exists', () => {
const userStatusFunc = eventFuncMap.get('user status')
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
realtime.notes[noteId] = {}
delete realtime.users[clientSocket.id]
const userData = {
idle: true,
type: 'xs'
}
userStatusFunc(userData)
assert(emitUserStatusStub.called === false)
})
it('should not call emitUserStatus when note not exists', () => {
const userStatusFunc = eventFuncMap.get('user status')
const emitUserStatusStub = sinon.stub(realtime, 'emitUserStatus')
realtime.deleteAllNoteFromPool()
realtime.users[clientSocket.id] = {}
const userData = {
idle: true,
type: 'xs'
}
userStatusFunc(userData)
assert(emitUserStatusStub.called === false)
})
})
describe('disconnect', function () {
it('should push socket to disconnect queue and call disconnect function', () => {
const disconnectFunc = eventFuncMap.get('disconnect')
const queueForDisconnectStub = sinon.stub(realtime, 'queueForDisconnect')
disconnectFunc()
assert(queueForDisconnectStub.calledOnce)
})
it('should quick return when socket is in disconnect queue', () => {
const disconnectFunc = eventFuncMap.get('disconnect')
const queueForDisconnectStub = sinon.stub(realtime, 'queueForDisconnect')
realtime.disconnectProcessQueue.push(clientSocket.id, async () => {})
disconnectFunc()
assert(queueForDisconnectStub.called === false)
})
})
;['cursor focus', 'cursor activity', 'cursor blur'].forEach((event) => {
describe(event, function () {
let cursorFocusFunc
const cursorData = {
cursor: 10
}
beforeEach(() => {
cursorFocusFunc = eventFuncMap.get(event)
realtime.notes[noteId] = {}
})
it('should broadcast to all client', () => {
cursorFocusFunc(cursorData)
const broadChannelEmitFake = clientSocket.broadcast.to(noteId).emit
assert(broadChannelEmitFake.calledOnce)
assert(broadChannelEmitFake.lastCall.args[0] === event)
if (event === 'cursor blur') {
assert(broadChannelEmitFake.lastCall.args[1].id === clientSocket.id)
} else {
assert.deepStrictEqual(broadChannelEmitFake.lastCall.args[1].cursor, cursorData)
}
})
it('should not broadcast when note not exists', () => {
delete realtime.notes[noteId]
cursorFocusFunc(cursorData)
const broadChannelEmitFake = clientSocket.broadcast.to(noteId).emit
assert(broadChannelEmitFake.called === false)
})
it('should not broadcast when user not exists', () => {
delete realtime.users[clientSocket.id]
cursorFocusFunc(cursorData)
const broadChannelEmitFake = clientSocket.broadcast.to(noteId).emit
assert(broadChannelEmitFake.called === false)
})
})
})
describe('version', function () {
it('should emit server version ', () => {
const versionFunc = eventFuncMap.get('version')
versionFunc()
assert(clientSocket.emit.called)
assert(clientSocket.emit.lastCall.args[0], 'version')
assert.deepStrictEqual(clientSocket.emit.lastCall.args[1], {
version: '1.5.0',
minimumCompatibleVersion: '1.0.0'
})
})
})
describe('online users', function () {
it('should return online user list', function () {
const onlineUsersFunc = eventFuncMap.get('online users')
realtime.notes[noteId] = {
users: {
10: {
id: 10
},
20: {
id: 20
}
}
}
onlineUsersFunc()
assert(clientSocket.emit.called)
assert(clientSocket.emit.lastCall.args[0] === 'online users')
const returnUserList = clientSocket.emit.lastCall.args[1].users
assert(returnUserList.length === 2)
assert(returnUserList[0].id === 10)
assert(returnUserList[1].id === 20)
})
it('should not return user list when note not exists', function () {
const onlineUsersFunc = eventFuncMap.get('online users')
realtime.deleteAllNoteFromPool()
onlineUsersFunc()
assert(clientSocket.emit.called === false)
})
})
describe('user changed', function () {
it('should call updateUserData', () => {
const userChangedFunc = eventFuncMap.get('user changed')
realtime.notes[noteId] = {
users: {
[clientSocket.id]: {}
}
}
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
userChangedFunc()
assert(updateUserDataStub.calledOnce)
assert(emitOnlineUsersStub.calledOnce)
})
it('should direct return when note not exists', function () {
const userChangedFunc = eventFuncMap.get('user changed')
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
realtime.deleteAllNoteFromPool()
userChangedFunc()
assert(updateUserDataStub.called === false)
assert(emitOnlineUsersStub.called === false)
})
it('should direct return when note\'s users not exists', function () {
const userChangedFunc = eventFuncMap.get('user changed')
realtime.notes[noteId] = {
users: {}
}
delete realtime.users[clientSocket.id]
const updateUserDataStub = sinon.stub(realtime, 'updateUserData')
const emitOnlineUsersStub = sinon.stub(realtime, 'emitOnlineUsers')
userChangedFunc()
assert(updateUserDataStub.called === false)
assert(emitOnlineUsersStub.called === false)
})
})
describe('delete', function () {
it('should delete note when owner request', function (done) {
const currentUserId = 'user1_id'
const noteOwnerId = 'user1_id'
const otherClient = makeMockSocket()
clientSocket.request = {
user: {
logged_in: true,
id: currentUserId
}
}
realtime.notes[noteId] = {
owner: noteOwnerId,
socks: [clientSocket, undefined, otherClient]
}
const deleteFunc = eventFuncMap.get('delete')
deleteFunc()
setTimeout(() => {
assert(otherClient.disconnect.calledOnce)
assert(otherClient.emit.calledOnce)
assert(otherClient.emit.lastCall.args[0] === 'delete')
assert(clientSocket.disconnect.calledOnce)
assert(clientSocket.emit.calledOnce)
assert(clientSocket.emit.lastCall.args[0] === 'delete')
assert(modelsMock.Note.destroy.calledOnce)
done()
}, 10)
})
it('should not do anything when user not login', function (done) {
const noteOwnerId = 'user1_id'
clientSocket.request = {}
realtime.notes[noteId] = {
owner: noteOwnerId,
socks: [clientSocket]
}
const deleteFunc = eventFuncMap.get('delete')
deleteFunc()
setTimeout(() => {
assert(modelsMock.Note.destroy.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 10)
})
it('should not do anything when note not exists', function (done) {
const currentUserId = 'user1_id'
clientSocket.request = {
user: {
logged_in: true,
id: currentUserId
}
}
const deleteFunc = eventFuncMap.get('delete')
deleteFunc()
setTimeout(() => {
assert(modelsMock.Note.destroy.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 10)
})
it('should not do anything when note owner is not me', function (done) {
const currentUserId = 'user1_id'
const noteOwnerId = 'user2_id'
const otherClient = makeMockSocket()
clientSocket.request = {
user: {
logged_in: true,
id: currentUserId
}
}
realtime.notes[noteId] = {
owner: noteOwnerId,
socks: [clientSocket, otherClient]
}
const deleteFunc = eventFuncMap.get('delete')
deleteFunc()
setTimeout(() => {
assert(clientSocket.disconnect.called === false)
assert(modelsMock.Note.destroy.called === false)
done()
}, 10)
})
it('should not do anything when note destroy fail', function (done) {
const currentUserId = 'user1_id'
const noteOwnerId = 'user1_id'
modelsMock.Note.destroy.withArgs({
where: {
id: noteId
}
}).returns(Promise.resolve(0))
const otherClient = makeMockSocket()
clientSocket.request = {
user: {
logged_in: true,
id: currentUserId
}
}
realtime.notes[noteId] = {
id: noteId,
owner: noteOwnerId,
socks: [clientSocket, otherClient]
}
const deleteFunc = eventFuncMap.get('delete')
deleteFunc()
setTimeout(() => {
assert(modelsMock.Note.destroy.calledOnce)
assert(clientSocket.disconnect.called === false)
done()
}, 10)
})
})
describe('permission', function () {
const ownerId = 'user1_id'
const otherSignInUserId = 'user2_id'
let otherClient
let checkViewPermissionSpy
let permissionFunc
beforeEach(function () {
otherClient = makeMockSocket()
clientSocket.request = {
user: {
id: ownerId,
logged_in: true
}
}
otherClient.request = {
user: {
id: otherSignInUserId,
logged_in: true
}
}
realtime.deleteAllNoteFromPool()
realtime.addNote({
id: noteId,
owner: ownerId
})
checkViewPermissionSpy = sinon.spy(realtime, 'checkViewPermission')
permissionFunc = eventFuncMap.get('permission')
})
it('should disconnect when lose view permission', function (done) {
realtime.getNoteFromNotePool(noteId).permission = 'editable'
realtime.getNoteFromNotePool(noteId).socks = [clientSocket, undefined, otherClient]
permissionFunc('private')
setTimeout(() => {
// should change note permission to private
assert(modelsMock.Note.update.calledOnce)
assert(modelsMock.Note.update.lastCall.args[0].permission === 'private')
assert(modelsMock.Note.update.lastCall.args[1].where.id === noteId)
// should check all connected client
assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.calledOnce)
assert(otherClient.disconnect.calledOnce)
done()
}, 5)
})
it('should not do anything when user not logged in', function (done) {
clientSocket.request = {}
permissionFunc('private')
setTimeout(() => {
assert(modelsMock.Note.update.called === false)
done()
}, 5)
})
it('should not do anything when note not exists', function (done) {
delete realtime.notes[noteId]
permissionFunc('private')
setTimeout(() => {
assert(modelsMock.Note.update.called === false)
done()
}, 5)
})
it('should not do anything when not note owner', function (done) {
clientSocket.request.user.id = 'other_user_id'
permissionFunc('private')
setTimeout(() => {
assert(modelsMock.Note.update.called === false)
done()
}, 5)
})
it('should change permission to freely when config allowAnonymous, allowAnonymousEdits and allowAnonymousViews are true', function (done) {
configMock.allowAnonymous = true
configMock.allowAnonymousEdits = true
configMock.allowAnonymousViews = true
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('freely')
setTimeout(() => {
assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.called === false)
assert(otherClient.disconnect.called === false)
assert(clientSocket.emit.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 5)
})
it('should not change permission to freely when config allowAnonymous, allowAnonymousEdits and allowAnonymousViews are false', function (done) {
configMock.allowAnonymous = false
configMock.allowAnonymousEdits = false
configMock.allowAnonymousViews = false
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('freely')
setTimeout(() => {
assert(modelsMock.Note.update.called === false)
assert(checkViewPermissionSpy.called === false)
done()
}, 5)
})
it('should change permission to freely when config allowAnonymous is true', function (done) {
configMock.allowAnonymous = true
configMock.allowAnonymousEdits = false
configMock.allowAnonymousViews = false
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('freely')
setTimeout(() => {
assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.called === false)
assert(otherClient.disconnect.called === false)
assert(clientSocket.emit.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 5)
})
it('should not change permission to freely when config allowAnonymousEdits is true', function (done) {
configMock.allowAnonymous = false
configMock.allowAnonymousEdits = true
configMock.allowAnonymousViews = false
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('freely')
setTimeout(() => {
assert(modelsMock.Note.update.called === false)
assert(checkViewPermissionSpy.called === false)
done()
}, 5)
})
it('should not change permission to freely when config allowAnonymousViews is true', function (done) {
configMock.allowAnonymous = false
configMock.allowAnonymousEdits = false
configMock.allowAnonymousViews = true
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('freely')
setTimeout(() => {
assert(modelsMock.Note.update.called === false)
assert(checkViewPermissionSpy.called === false)
done()
}, 5)
})
it('should change permission to editable when config allowAnonymousViews is true', function (done) {
configMock.allowAnonymous = false
configMock.allowAnonymousEdits = false
configMock.allowAnonymousViews = true
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('editable')
setTimeout(() => {
assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.called === false)
assert(otherClient.disconnect.called === false)
assert(clientSocket.emit.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 5)
})
it('should change permission to freely when config allowAnonymousEdits and allowAnonymousViews are false true', function (done) {
configMock.allowAnonymous = false
configMock.allowAnonymousEdits = true
configMock.allowAnonymousViews = true
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('freely')
setTimeout(() => {
assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.called === false)
assert(otherClient.disconnect.called === false)
assert(clientSocket.emit.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 5)
})
it('should change permission to editable when config allowAnonymousEdits and allowAnonymousViews are false true', function (done) {
configMock.allowAnonymous = false
configMock.allowAnonymousEdits = true
configMock.allowAnonymousViews = true
realtime.notes[noteId].socks = [clientSocket, undefined, otherClient]
permissionFunc('editable')
setTimeout(() => {
assert(checkViewPermissionSpy.callCount === 2)
assert(otherClient.emit.called === false)
assert(otherClient.disconnect.called === false)
assert(clientSocket.emit.called === false)
assert(clientSocket.disconnect.called === false)
done()
}, 5)
})
})
})

Some files were not shown because too many files have changed in this diff Show More