diff --git a/.github/ISSUE_TEMPLATE/bug_resolution.yml b/.github/ISSUE_TEMPLATE/bug_resolution.yml index 9e8efa655..cdd7418f0 100644 --- a/.github/ISSUE_TEMPLATE/bug_resolution.yml +++ b/.github/ISSUE_TEMPLATE/bug_resolution.yml @@ -55,7 +55,7 @@ body: - type: dropdown id: operating-system attributes: - label: What opreating system are you using? + label: Which operating system are you using? multiple: true options: - Linux/BSD/UNIX @@ -89,6 +89,7 @@ body: label: Version description: What version of CryptPad are you running? options: + - 5.6.0 - 5.5.0 - 5.4.1 - 5.4.0 @@ -99,11 +100,6 @@ body: - 5.0.0 - 4.14.1 - 4.14.0 - - 4.13.0 - - 4.12.1 - - 4.12.0 - - 4.11.0 - - 4.10.0 - Other validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a227a522..87109b4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,120 @@ SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and cont SPDX-License-Identifier: AGPL-3.0-or-later --> +# 5.6.0 + + +## Goals + +This release introduces support for integrating CryptPad instances with Single-Sign On authentication. It brings a lot of improvements and fixes to Form, Calendar, and other parts of CryptPad. This release begins to improve the accessibility of the toolbar towards full WCAG compliance which we hope to achieve in the near future. + +## Features + +- Authentication + - This version paves the way for SSO authentication for a CryptPad instance via a plugin (est. release Jan. 2024) [#1320](https://github.com/cryptpad/cryptpad/pull/1320) + - New setting to make Two-Factor Authentication mandatory for all user accounts on an instance [#1341](https://github.com/cryptpad/cryptpad/pull/1341) +- Form + - New button to duplicate a question [#1305](https://github.com/cryptpad/cryptpad/pull/1305) +- Calendar + - New description field for calendar events [#1299](https://github.com/cryptpad/cryptpad/pull/1299) + +## Improvements + +- Accessibility of toolbars and some drop-down menus [#1290](https://github.com/cryptpad/cryptpad/pull/1290) + - "+ New" drop-down menu in Drive and Team Drive #1191 + - New `Ctrl + e` modal #1192 + - Code contact request notifications as headings #1197 + - DOM order of toolbar #1198 + - Notifications menu not accessible via Keyboard #1201 + - Sidebar "tabs" not accessible via keyboard #1203 + - Implement keyboard navigation of toolbar menus #1209 + - CryptDrive page needs a logical tab order #1151 + - Elements not accessible using the keyboard #1162 + - Calendar event modal date-picker is cut-off at some screen resolutions #1280 + - Visible focus #1206 +- Rich Text + - Improvements to the Rich Text toolbar and layout for mobile usage [#1296](https://github.com/cryptpad/cryptpad/pull/1296) +- Calendar + - Handling the move of repeating events from a calendar to another [#1308](https://github.com/cryptpad/cryptpad/pull/1308) +- Kanban + - Changed positioning of kanban tag container on smaller screens [#1307](https://github.com/cryptpad/cryptpad/pull/1307) +- New option to increase the number of teams slots for premium users only [#1315](https://github.com/cryptpad/cryptpad/pull/1315) +- Improve licensing information, CryptPad code now complies with the [REUSE](https://reuse.software/) specifications [#1300](https://github.com/cryptpad/cryptpad/pull/1300) +- Deployment + - Basic configuration for Apache HTTPd [#1332](https://github.com/cryptpad/cryptpad/pull/1332) + - Add Docker health check [#1287](https://github.com/cryptpad/cryptpad/pull/1287) +- Cleanup + - Old // XXX comments [#1334](https://github.com/cryptpad/cryptpad/pull/1334) + - Outdated/misplaced files [#1327](https://github.com/cryptpad/cryptpad/pull/1327) + +## Fixes + +- Fix browser autocomplete issues (password, numbers, etc.) [#1342](https://github.com/cryptpad/cryptpad/pull/1342) +- Drive + - Container height fills screen [#1304](https://github.com/cryptpad/cryptpad/pull/1304) + - Context menu on mobile [#1301](https://github.com/cryptpad/cryptpad/pull/1301) +- OnlyOffice applications + - Use correct mime type for .wasm files (export functionality) [#1288](https://github.com/cryptpad/cryptpad/pull/1288) + - Fix filter functionality in Sheets [#1319](https://github.com/cryptpad/cryptpad/issues/1319) +- Form + - Fix an error upon importing a template in forms [#1316](https://github.com/cryptpad/cryptpad/pull/1316) + - Can now set form closing date/time on mobile [#1305](https://github.com/cryptpad/cryptpad/pull/1305) + - Can now edit time options for poll questions on mobile [#1305](https://github.com/cryptpad/cryptpad/pull/1305) + - Dates in CSV exports of forms are now in ISO (not timestamp) format [#1305](https://github.com/cryptpad/cryptpad/pull/1305) + - Page breaks are no longer visible in conditional sections when condition is not met [#1305](https://github.com/cryptpad/cryptpad/pull/1305) + - Final submission page now has margins [#1305](https://github.com/cryptpad/cryptpad/pull/1305) + - Question blocks on mobile are now only draggable at the top of the block to make scrolling possible [#1305](https://github.com/cryptpad/cryptpad/pull/1305) +- Whiteboard + - Fix a few export-related issues [#1328](https://github.com/cryptpad/cryptpad/pull/1328) +- Calendar + - Reformat `www/calendar/export.js` [#1314](https://github.com/cryptpad/cryptpad/pull/1314) + - Fix a bug with stopping the recurrence of a calendar event [#1312](https://github.com/cryptpad/cryptpad/pull/1312) + - Calendar creates itself twice when navigating with the keyboard [#1250](https://github.com/cryptpad/cryptpad/issues/1250) + - Fix timezone in Daylight Saving Time issues [#1317](https://github.com/cryptpad/cryptpad/pull/1317) +- Translations + - Revise the translation of `zh` [#1329](https://github.com/cryptpad/cryptpad/pull/1329) + +## Dependencies +- Added [Moment.js](http://momentjs.com/) for improved handling of dates in Calendar (added as part of [#1317](https://github.com/cryptpad/cryptpad/pull/1317)) + +## Deployment +We [fixed an issue with the Systemd service file and logging](https://github.com/cryptpad/cryptpad/commit/078095c3e25d39707bdaab7ec066ceed6cb7158b), you'll need to add the following lines to your `cryptpad.service` before continuing by following the upgrade notes below. + +```diff +# Restart service after 10 seconds if node service crashes +RestartSec=2 + ++ # Proper logging to journald ++ StandardOutput=journal ++ StandardError=journal+console + +User=cryptpad +Group=cryptpad +``` + +## Upgrade notes + +If you are upgrading from a version older than `5.5.0` please read the upgrade notes of all versions between yours and `5.5.0` to avoid configuration issues. + +To upgrade: + +1. Reload the Systemd daemon, required due to the changes in the **Deployment** section +```bash +sudo systemctl daemon-reload +``` + +2. Stop your server +3. Get the latest code with git +```bash +git fetch origin --tags +git checkout 5.6.0 +``` + +4. Restart your server +5. Review your instance's checkup page to ensure that you are passing all tests + + + # 5.5.0 ## Features diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 3889dbaf6..90001563f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -61,7 +61,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at contact@cryptpad.fr. All +reported by contacting the project team at contact@cryptpad.org. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/Dockerfile b/Dockerfile index cd8397bfd..36e92e747 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,14 +12,9 @@ WORKDIR /cryptpad # Copy CryptPad source code to the container COPY . /cryptpad -RUN sed -i "s@//httpAddress: '::'@httpAddress: '0.0.0.0'@" /cryptpad/config/config.example.js +RUN sed -i "s@//httpAddress: 'localhost'@httpAddress: '0.0.0.0'@" /cryptpad/config/config.example.js RUN sed -i "s@installMethod: 'unspecified'@installMethod: 'docker'@" /cryptpad/config/config.example.js - -# Install wget for healthcheck -RUN apt-get update && apt-get install --no-install-recommends -y wget && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - + # Install dependencies RUN npm install --production \ && npm run install:components @@ -31,6 +26,11 @@ FROM node:lts-slim RUN groupadd cryptpad -g 4001 RUN useradd cryptpad -u 4001 -g 4001 -d /cryptpad +# Install wget for healthcheck +RUN apt-get update && apt-get install --no-install-recommends -y wget && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + # Copy cryptpad with installed modules COPY --from=build --chown=cryptpad /cryptpad /cryptpad USER cryptpad diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index 32c6d733d..12c3736fc 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -365,10 +365,9 @@ .cp-toolbar { * { - outline-width: 0; + outline-style: none; &:focus { - // color shows on chrome/edge, but not firefox - outline-color: @cryptpad_color_brand; + outline: @cryptpad_color_brand solid 2px; } } @@ -780,6 +779,7 @@ padding: 10px; color: @toolbar-bg-color; color: var(--toolbar-bg-color); + border-radius: 5px; span { font-size: 45px; diff --git a/lib/eviction.js b/lib/eviction.js index 8bd530f1e..e57041ffb 100644 --- a/lib/eviction.js +++ b/lib/eviction.js @@ -200,6 +200,11 @@ var evictArchived = function (Env, cb) { // but if it's been stored for the configured time... // expire it + if (Env.DRY_RUN) { + if (item.channel.length === 32) { removed++; } + else if (item.channel.length === 44) { accounts++; } + return void Log.info("EVICT_ARCHIVED_CHANNEL_DRY_RUN", item.channel, cb); + } store.removeArchivedChannel(item.channel, w(function (err) { if (err) { return Log.error('EVICT_ARCHIVED_CHANNEL_REMOVAL_ERROR', { @@ -245,7 +250,11 @@ var evictArchived = function (Env, cb) { Log.error("EVICT_BLOB_LIST_ARCHIVED_PROOF_ERROR", err); return void next(); } - if (item && item.mtime > retentionTime) { return void next(); } + if (item && item.ctime > retentionTime) { return void next(); } + if (Env.DRY_RUN) { + removed++; + return void Log.info("EVICT_ARCHIVED_BLOB_PROOF_DRY_RUN", item, next); + } blobs.remove.archived.proof(item.safeKey, item.blobId, (function (err) { if (err) { Log.error("EVICT_ARCHIVED_BLOB_PROOF_ERROR", item); @@ -272,7 +281,11 @@ var evictArchived = function (Env, cb) { Log.error("EVICT_BLOB_LIST_ARCHIVED_BLOBS_ERROR", err); return void next(); } - if (item && item.mtime > retentionTime) { return void next(); } + if (item && item.ctime > retentionTime) { return void next(); } + if (Env.DRY_RUN) { + removed++; + return void Log.info("EVICT_ARCHIVED_BLOB_DRY_RUN", item, next); + } blobs.remove.archived.blob(item.blobId, function (err) { if (err) { Log.error("EVICT_ARCHIVED_BLOB_ERROR", item); @@ -288,6 +301,7 @@ var evictArchived = function (Env, cb) { })); }; + if (Env.DRY_RUN) { Env.Log.info('DRY RUN'); } nThen(loadStorage) .nThen(migrateIncorrectBlobs) .nThen(removeArchivedChannels) @@ -544,6 +558,9 @@ module.exports = function (Env, cb) { } // remove the pin logs of inactive accounts if inactive account removal is configured + if (Env.DRY_RUN) { + return void Log.info("EVICT_INACTIVE_ACCOUNT_DRY_RUN", id, next); + } pinStore.archiveChannel(id, undefined, function (err) { if (err) { return Log.error('EVICT_INACTIVE_ACCOUNT_PIN_LOG', err, next); @@ -602,7 +619,12 @@ module.exports = function (Env, cb) { // unless we address this race condition with this last-minute double-check if (item.mtime > inactiveTime) { return void next(); } - removed++; + if (Env.DRY_RUN) { + removed++; + return void Log.info("EVICT_ARCHIVE_BLOB_DRY_RUN", { + item: item, + }, next); + } blobs.archive.blob(item.blobId, 'INACTIVE', function (err) { if (err) { return Log.error("EVICT_ARCHIVE_BLOB_ERROR", { @@ -610,6 +632,7 @@ module.exports = function (Env, cb) { item: item, }, next); } + removed++; Log.info("EVICT_ARCHIVE_BLOB", { item: item, }, next); @@ -658,6 +681,10 @@ module.exports = function (Env, cb) { } })); }).nThen(function () { + if (Env.DRY_RUN) { + removed++; + return void Log.info("EVICT_BLOB_PROOF_LONELY_DRY_RUN", item, next); + } blobs.remove.proof(item.safeKey, item.blobId, function (err) { if (err) { return Log.error("EVICT_BLOB_PROOF_LONELY_ERROR", item, next); @@ -698,6 +725,9 @@ module.exports = function (Env, cb) { // check if the database has any ephemeral channels // if it does it's because of a bug, and they should be removed if (item.channel.length === 34) { + if (Env.DRY_RUN) { + return void Log.info("EVICT_EPHEMERAL_DRY_RUN", item.channel, cb); + } return void store.removeChannel(item.channel, w(function (err) { if (err) { return Log.error('EVICT_EPHEMERAL_CHANNEL_REMOVAL_ERROR', { @@ -728,6 +758,11 @@ module.exports = function (Env, cb) { // else fall through to the archival })); }).nThen(function (w) { + if (Env.DRY_RUN) { + archived++; + w.abort(); + return void Log.info("EVICT_CHANNEL_ARCHIVAL_DRY_RUN", item.channel, cb); + } return void store.archiveChannel(item.channel, 'INACTIVE', w(function (err) { if (err) { Log.error('EVICT_CHANNEL_ARCHIVAL_ERROR', { @@ -736,8 +771,8 @@ module.exports = function (Env, cb) { }, w()); return; } - Log.info('EVICT_CHANNEL_ARCHIVAL', item.channel, w()); archived++; + Log.info('EVICT_CHANNEL_ARCHIVAL', item.channel, w()); })); }).nThen(cb); }; @@ -754,6 +789,7 @@ module.exports = function (Env, cb) { store.listChannels(handler, w(done), true); // using a hacky "fast mode" since we only need the channel id }; + if (Env.DRY_RUN) { Env.Log.info('DRY RUN'); } nThen(loadStorage) // iterate over all documents and add them to a bloom filter if they have been active diff --git a/lib/storage/blob.js b/lib/storage/blob.js index 97fc8b9e3..b029b95c5 100644 --- a/lib/storage/blob.js +++ b/lib/storage/blob.js @@ -153,8 +153,12 @@ var clearActivity = function (Env, blobId, cb) { }; var updateActivity = function (Env, blobId, cb) { var path = makeActivityPath(Env, blobId); - var s_data = String(+new Date()); - Fs.writeFile(path, s_data, cb); + var blobPath = makeBlobPath(Env, blobId); + isFile(blobPath, (err, state) => { + if (err || !state) { return void cb(); } + var s_data = String(+new Date()); + Fs.writeFile(path, s_data, cb); + }); }; var archiveActivity = function (Env, blobId, cb) { @@ -464,7 +468,7 @@ var makeWalker = function (n, handleChild, done) { // do no more than 20 jobs at a time var tasks = Semaphore.create(n); - var recurse = function (path) { + var recurse = function (path, dir) { tasks.take(function (give) { var next = give(W()); @@ -477,7 +481,19 @@ var makeWalker = function (n, handleChild, done) { } if (!stats.isDirectory()) { w.abort(); - return void handleChild(void 0, path, next); + if (/\.activity$/.test(path)) { + // NOTE: some activity files were created for deleted blobs due to + // a bug. We're going to detect them here in order to be able to clean + // them. + if (!dir.includes(Path.basename(path.replace(/\.activity$/, '')))) { + return void handleChild(void 0, path, next, true); + } + // Ignore valid activity files + return next(); + } + // Ignore placeholder files + if (/\.placeholder$/.test(path)) { return next(); } + return void handleChild(void 0, path, next, false); } // fall through })); @@ -487,7 +503,7 @@ var makeWalker = function (n, handleChild, done) { if (err) { return next(); } // everything is fine and it's a directory... dir.forEach(function (d) { - recurse(Path.join(path, d)); + recurse(Path.join(path, d), dir); }); next(); }); @@ -502,7 +518,8 @@ var listProofs = function (root, handler, cb) { Fs.readdir(root, function (err, dir) { if (err) { return void cb(err); } - var walk = makeWalker(20, function (err, path, next) { + var walk = makeWalker(20, function (err, path, next, loneActivity) { + if (loneActivity) { return void next(); } // path is the path to a child node on the filesystem // next handles the next job in a queue @@ -537,12 +554,20 @@ var listProofs = function (root, handler, cb) { }); }; +var getActivityStat = function (path, base, cb) { + var suffix = base ? '' : '.activity'; + Fs.stat(path+suffix, function (err, stats) { + if (err && err.code === 'ENOENT' && !base) { return getActivityStat(path, true, cb); } + cb(err, stats); + }); +}; var listBlobs = function (root, handler, cb) { // iterate over files Fs.readdir(root, function (err, dir) { if (err) { return void cb(err); } - var walk = makeWalker(20, function (err, path, next) { - Fs.stat(path, function (err, stats) { + var walk = makeWalker(20, function (err, path, next, loneActivity) { + if (loneActivity) { return void next(); } + getActivityStat(path, false, function (err, stats) { if (err) { return void handler(err, void 0, next); } @@ -565,6 +590,30 @@ var listBlobs = function (root, handler, cb) { }); }; +var cleanLoneActivity = function (root, cb) { + // iterate over files + Fs.readdir(root, function (err, dir) { + if (err) { return void cb(err); } + var walk = makeWalker(20, function (err, path, next, loneActivity) { + if (!loneActivity) { return void next(); } + Fs.unlink(path, function (err) { + if (err) { + return console.error('ERROR', path, err); + } + console.log('DELETED', path); + next(); + }); + }, function () { + cb(); + }); + + dir.forEach(function (d) { + if (d.length !== 2) { return; } + walk(Path.join(root, d)); + }); + }); +}; + BlobStore.create = function (config, _cb) { var cb = Util.once(Util.mkAsync(_cb)); if (typeof(config.getSession) !== 'function') { @@ -651,6 +700,10 @@ BlobStore.create = function (config, _cb) { removeArchivedProof(Env, safeKey, blobId, cb); }, }, + loneActivity: function (_cb) { + var cb = Util.once(Util.mkAsync(_cb)); + cleanLoneActivity(Env.blobPath, cb); + } }, archive: { diff --git a/package-lock.json b/package-lock.json index 34e3156bd..2476d1545 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2028,9 +2028,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", - "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz", + "integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==", "funding": [ { "type": "individual", diff --git a/readme.md b/readme.md index 571fd5948..e290cbb82 100644 --- a/readme.md +++ b/readme.md @@ -32,7 +32,7 @@ You can find `Dockerfile`, `docker-compose.yml` and `docker-entrypoint.sh` files Previously, Docker images were community maintained, had their own repository and weren't official supported. We changed that with v5.4.0 during July 2023. Thanks to @promasu for all the work on the community images. -# Security +# Privacy / Security CryptPad offers a variety of collaborative tools that encrypt your data in your browser before it is sent to the server and your collaborators. In the event that the server is @@ -95,7 +95,7 @@ This project is tested with [BrowserStack](https://www.browserstack.com/). This software is and will always be available under the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. If you wish to use this technology in a proprietary product, please contact -sales@xwiki.com. +sales@cryptpad.org [Tor browser]: https://www.torproject.org/download/ [active attack]: https://en.wikipedia.org/wiki/Attack_(computing)#Types_of_attack diff --git a/scripts/clean-activity.js b/scripts/clean-activity.js new file mode 100644 index 000000000..dbb58c8f3 --- /dev/null +++ b/scripts/clean-activity.js @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2023 XWiki CryptPad Team and contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Some .activity file were created for deleted blob due to a bug. + * This script can be run once to remove these invalid activity file. +**/ +var config = require("../lib/load-config"); +var BlobStore = require("../lib/storage/blob"); + +config.getSession = function () {}; +BlobStore.create(config, function (err, store) { + if (err) { return console.error('ERROR', err); } + console.log('Cleaning lone .activity files...'); + store.remove.loneActivity(function (err) { + if (err) { return console.error('ERROR', err); } + console.log('Done'); + }); +}); diff --git a/scripts/evict-archived.js b/scripts/evict-archived.js index 124ebc30c..6914c90ad 100644 --- a/scripts/evict-archived.js +++ b/scripts/evict-archived.js @@ -15,6 +15,10 @@ var config = require("../lib/load-config"); var Env = Environment.create(config); +// Set DRY_RUN to true to run the script without deleting anything. A log file +// will be created. +Env.DRY_RUN = false; + var loadPremiumAccounts = function (Env, cb) { nThen(function (w) { // load premium accounts diff --git a/scripts/evict-inactive.js b/scripts/evict-inactive.js index f53536ae3..521edf015 100644 --- a/scripts/evict-inactive.js +++ b/scripts/evict-inactive.js @@ -15,6 +15,10 @@ var config = require("../lib/load-config"); var Env = Environment.create(config); +// Set DRY_RUN to true to run the script without deleting anything. A log file +// will be created. +Env.DRY_RUN = false; + var loadPremiumAccounts = function (Env, cb) { nThen(function (w) { // load premium accounts diff --git a/scripts/translations/lint-translations.js b/scripts/translations/lint-translations.js index f57bc14de..7a9d385ac 100644 --- a/scripts/translations/lint-translations.js +++ b/scripts/translations/lint-translations.js @@ -53,7 +53,7 @@ special_rules.fr = function (s) { ignore instances where the following character is a '/' because this is probably a URL (http(s)://) */ - return /\S[:;\?\!][^\/]{1,}/.test(s); + return /\S[:;\?\!][^\/]{1,}/.test(s.replace(/mailto:/g, " :")); }; var noop = function () {}; diff --git a/www/common/common-thumbnail.js b/www/common/common-thumbnail.js index 2298a904a..7dcf55455 100644 --- a/www/common/common-thumbnail.js +++ b/www/common/common-thumbnail.js @@ -230,17 +230,17 @@ define([ Thumb.fromDOM = function (opts, cb) { var element = opts.getContainer(); if (!element) { return; } - var todo = function () { + var todo = function (html2canvas) { + if (!window.html2canvas) { window.html2canvas = html2canvas; } if (opts.filter) { opts.filter(element, true); } window.html2canvas(element, { allowTaint: true, - onrendered: function (canvas) { - if (opts.filter) { opts.filter(element, false); } - setTimeout(function () { - var D = getResizedDimensions(canvas, 'pad'); - Thumb.fromCanvas(canvas, D, cb); - }, 10); - } + }).then(function (canvas) { + if (opts.filter) { opts.filter(element, false); } + setTimeout(function () { + var D = getResizedDimensions(canvas, 'pad'); + Thumb.fromCanvas(canvas, D, cb); + }, 10); }); }; if (window.html2canvas) { return void todo(); } diff --git a/www/common/common-util.js b/www/common/common-util.js index a6be79628..c6f1e2b4e 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -743,6 +743,11 @@ return !(typeof(Atomics) === "undefined" || !supportsSharedArrayBuffers() || typeof(WebAssembly) === 'undefined'); }; + //Returns an array of integers in range 0 to (length-1) + Util.getKeysArray = function (length) { + return [...Array(length).keys()]; + }; + if (typeof(module) !== 'undefined' && module.exports) { module.exports = Util; } else if ((typeof(define) !== 'undefined' && define !== null) && (define.amd !== null)) { diff --git a/www/common/drive-ui.js b/www/common/drive-ui.js index 6bd2699bf..b1803a7d3 100644 --- a/www/common/drive-ui.js +++ b/www/common/drive-ui.js @@ -636,7 +636,7 @@ define([ // UI containers var $tree = APP.$tree = $("#cp-app-drive-tree"); var $content = APP.$content = $("#cp-app-drive-content"); - var $contentContainer = APP.$content = $("#cp-app-drive-content-container"); + var $contentContainer = $("#cp-app-drive-content-container"); var $appContainer = $(".cp-app-drive-container"); var $driveToolbar = APP.toolbar.$bottom; var $contextMenu = createContextMenu(common).appendTo($appContainer); diff --git a/www/common/inner/access.js b/www/common/inner/access.js index d1cfda31f..acaee6723 100644 --- a/www/common/inner/access.js +++ b/www/common/inner/access.js @@ -1028,7 +1028,7 @@ define([ // If this is a form wiht a answer channel, delete it too var p = priv.propChannels; - if (p.answersChannel) { + if (p && p.answersChannel) { sframeChan.query('Q_DELETE_OWNED', { teamId: typeof(owned) !== "boolean" ? owned : undefined, channel: p.answersChannel diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index e8723d321..2672c4c06 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -1403,7 +1403,7 @@ define([ // Hidden hash: if a pad is deleted, we may have to switch back to full hash // in some tabs - Store.checkDeletedPad = function (channel) { + Store.checkDeletedPad = function (channel, cb) { if (!channel) { return; } // Check if the pad is still stored in one of our drives @@ -1411,6 +1411,7 @@ define([ channel: channel, isFile: true // we don't care if it's view or edit }, function (res) { + if (typeof(cb) === "function") { setTimeout(cb); } // If it is stored, abort if (Object.keys(res).length) { return; } // Otherwise, tell all the tabs that this channel was deleted and give them the hrefs diff --git a/www/common/proxy-manager.js b/www/common/proxy-manager.js index f09ac87b5..509e91a3a 100644 --- a/www/common/proxy-manager.js +++ b/www/common/proxy-manager.js @@ -586,7 +586,7 @@ define([ // 1. add the shared folder to our list of shared folders // NOTE: pushSharedFolder will encrypt the href directly in the object if needed Env.user.userObject.pushSharedFolder(folderData, waitFor(function (err, folderId) { - if (err === "EEXISTS" && folderData.href && folderId) { + if (err === "EEXISTS" && folderData.href && folderId) { // Check upgrade var parsed = Hash.parsePadUrl(folderData.href); var secret = Hash.getSecrets('drive', parsed.hash, folderData.password); SF.upgrade(secret.channel, secret); @@ -594,6 +594,10 @@ define([ waitFor.abort(); return void cb(folderId); } + if (err === "EEXISTS" && folderId) { // Exists but no upgrade, return folderId + waitFor.abort(); + return void cb(folderId); + } if (err) { waitFor.abort(); return void cb(err); @@ -1015,41 +1019,52 @@ define([ var owned = Env.user.userObject.ownedInTrash(function (owners) { return _ownedByMe(Env, owners); }); + var n = nThen; owned.forEach(function (chan) { - Env.removeOwnedChannel(chan, waitFor(function (obj) { - // If the error is that the file is already removed, nothing to - // report, it's a normal behavior (pad expired probably) - if (obj && obj.error && obj.error !== "ENOENT") { - // RPC may not be responding - // Send a report that can be handled manually - console.error(obj.error, chan); - Feedback.send('ERROR_EMPTYTRASH_OWNED=' + chan + '|' + obj.error, true); - } - console.warn('DELETED', chan); - })); + n = n(function (w) { + Env.removeOwnedChannel(chan, w(function (obj) { + setTimeout(w(), 50); + // If the error is that the file is already removed, nothing to + // report, it's a normal behavior (pad expired probably) + if (obj && obj.error && obj.error !== "ENOENT") { + // RPC may not be responding + // Send a report that can be handled manually + console.error(obj.error, chan); + Feedback.send('ERROR_EMPTYTRASH_OWNED=' + chan + '|' + obj.error, true); + } + console.warn('DELETED', chan); + })); + }).nThen; }); + n(waitFor()); } // Empty the trash Env.user.userObject.emptyTrash(waitFor(function (err, toClean) { - cb(); - + var nn = nThen; // Don't block nThen for the lower-priority tasks - setTimeout(function () { + setTimeout(waitFor(function () { // Unpin deleted pads if needed // Check if we need to restore a full hash (hidden hash deleted from drive) if (!Array.isArray(toClean)) { return; } + var done = waitFor(); var toCheck = Util.deduplicateString(toClean); var toUnpin = []; toCheck.forEach(function (channel) { // Check unpin - var data = findChannel(Env, channel, true); - if (!data.length) { toUnpin.push(channel); } - // Check hidden hash - Env.Store.checkDeletedPad(channel); + nn = nn(function (w) { + var data = findChannel(Env, channel, true); + if (!data.length) { toUnpin.push(channel); } + // Check hidden hash, one at a time, asynchronously + Env.Store.checkDeletedPad(channel, w()); + }).nThen; }); - Env.unpinPads(toUnpin, function () {}); - }); + nn(function () { + Env.unpinPads(toUnpin, function () { + done(); + }); + }); + })); })); }).nThen(cb); }; diff --git a/www/common/translations/messages.ar.json b/www/common/translations/messages.ar.json index e9a1d3bc5..f69f365ab 100644 --- a/www/common/translations/messages.ar.json +++ b/www/common/translations/messages.ar.json @@ -1165,7 +1165,6 @@ "calendar_update": "حَدِّثْ", "calendar_title": "عنوان", "calendar_loc": "موقع", - "calendar_location": "الموقع: {0}", "calendar_hours": "ساعات", "calendar_noNotification": "بدون إشعار", "calendar_allDay": "طوال اليوم", diff --git a/www/common/translations/messages.ca.json b/www/common/translations/messages.ca.json index 8485bdf19..1854164ef 100644 --- a/www/common/translations/messages.ca.json +++ b/www/common/translations/messages.ca.json @@ -1421,7 +1421,6 @@ "calendar_deleteTeamConfirm": "Esteu segur que voleu suprimir aquest calendari de l'equip?", "calendar_deleteOwned": "Continuarà sent visible per a altres usuaris amb els quals s'ha compartit.", "calendar_newEvent": "Esdeveniment nou", - "calendar_location": "Ubicació: {0}", "calendar_more": "{0} més", "reminder_missed": "{0} va tenir lloc el {1}", "reminder_now": "{0} ha començat", diff --git a/www/common/translations/messages.cs.json b/www/common/translations/messages.cs.json index f57b72c40..8769febb3 100644 --- a/www/common/translations/messages.cs.json +++ b/www/common/translations/messages.cs.json @@ -635,7 +635,6 @@ "calendar_hours": "Hodin", "calendar_minutes": "Minut", "calendar_allDay": "Celý den", - "calendar_location": "Místo: {0}", "calendar_loc": "Místo", "calendar_title": "Nadpis", "calendar_update": "Uložit", diff --git a/www/common/translations/messages.de.json b/www/common/translations/messages.de.json index 34a175d68..8528dc3f3 100644 --- a/www/common/translations/messages.de.json +++ b/www/common/translations/messages.de.json @@ -479,7 +479,7 @@ "mdToolbar_list": "Aufzählung", "mdToolbar_check": "Aufgabenliste", "mdToolbar_code": "Code", - "home_host": "Dies ist eine unabhängige Installation der CrypPad-Software.", + "home_host": "Dies ist eine unabhängige Instanz von CryptPad.", "main_catch_phrase": "Suite zur Zusammenarbeit
Ende-zu-Ende-verschlüsselt und quelloffen", "about": "Über uns", "privacy": "Datenschutzerklärung", @@ -1167,7 +1167,6 @@ "calendar_hours": "Stunden", "calendar_minutes": "Minuten", "calendar_allDay": "Ganztägig", - "calendar_location": "Ort: {0}", "calendar_loc": "Ort", "calendar_title": "Titel", "calendar_update": "Aktualisieren", @@ -1667,5 +1666,20 @@ "status": "Statusseite", "access_passwordUsed": "Dieses Passwort wurde bereits für dieses Dokument verwendet. Es kann nicht erneut verwendet werden.", "admin_diskUsageWarning": "Mit Vorsicht zu verwenden! Je nach Größe der auf der Instanz gespeicherten Daten kann die Erstellung dieses Berichts den gesamten auf dem Server verfügbaren Speicher verbrauchen und zu einem Absturz führen.", - "dph_sf_destroyed": "Geteilter Ordner {0} wurde vom Eigentümer zerstört" + "dph_sf_destroyed": "Geteilter Ordner {0} wurde vom Eigentümer zerstört", + "calendar_desc": "Beschreibung", + "admin_forcemfaHint": "Alle Benutzer dieser Instanz werden aufgefordert, eine Zwei-Faktor-Authentifizierung für die Anmeldung bei ihrem Account einzurichten.", + "ssoauth_form_hint_register": "Füge ein CryptPad-Passwort für zusätzliche Sicherheit hinzu oder lasse es leer und fahre fort. Wenn du kein Passwort hinzufügst, können die Administratoren der Instanz auf die Schlüssel zum Schutz deiner Daten zugreifen.", + "ssoauth_form_hint_login": "Bitte gib dein CryptPad-Passwort ein", + "loading_mfa_required": "Auf dieser Instanz ist eine Zwei-Faktor-Authentifizierung erforderlich. Bitte aktualisiere deinen Account mit einer Authentifizierungs-App und dem folgenden Formular.", + "calendar_description": "Beschreibung:{0}{1}", + "duplicate": "Duplizieren", + "kanban_hideTags": "Weniger Tags anzeigen", + "kanban_showTags": "Alle Tags anzeigen", + "sso_login_description": "Einloggen mit", + "sso_register_description": "Registrieren mit", + "ssoauth_header": "CryptPad-Passwort", + "calendar_rec_change": "Ein sich wiederholendes Ereignis wird in einen anderen Kalender verschoben. Du kannst diese Änderung nur auf dieses Ereignis oder alle wiederholten Ereignisse anwenden.", + "calendar_rec_change_first": "Das erste sich wiederholende Ereignis wird in einen anderen Kalender verschoben. Alle wiederholten Ereignisse werden ebenfalls verschoben.", + "admin_forcemfaTitle": "Verpflichtende Zwei-Faktor-Authentifizierung" } diff --git a/www/common/translations/messages.es.json b/www/common/translations/messages.es.json index 896967246..2c3df1e42 100644 --- a/www/common/translations/messages.es.json +++ b/www/common/translations/messages.es.json @@ -950,7 +950,6 @@ "calendar_hours": "Horas", "calendar_minutes": "Minutos", "calendar_allDay": "Todo el día", - "calendar_location": "Localización: {0}", "calendar_loc": "Localización", "calendar_title": "Título", "calendar_dateTimeRange": "{0} {1} - {2}", diff --git a/www/common/translations/messages.eu.json b/www/common/translations/messages.eu.json index f83f56c13..827b1bde0 100644 --- a/www/common/translations/messages.eu.json +++ b/www/common/translations/messages.eu.json @@ -1048,7 +1048,6 @@ "calendar_hours": "Orduak", "calendar_minutes": "Minutuak", "calendar_allDay": "Egunero", - "calendar_location": "Kokapena: {0}", "calendar_loc": "Kokapena", "calendar_title": "Izena", "calendar_update": "Eguneratu", diff --git a/www/common/translations/messages.fi.json b/www/common/translations/messages.fi.json index 04002ceae..a4881c436 100644 --- a/www/common/translations/messages.fi.json +++ b/www/common/translations/messages.fi.json @@ -1173,7 +1173,6 @@ "calendar_update": "Päivitä", "calendar_title": "Otsikko", "calendar_loc": "Sijainti", - "calendar_location": "Sijainti: {0}", "calendar_allDay": "Koko päivä", "calendar_minutes": "Minuutit", "calendar_hours": "Tunnit", diff --git a/www/common/translations/messages.fr.json b/www/common/translations/messages.fr.json index 5fcd1e670..fd9ab2939 100644 --- a/www/common/translations/messages.fr.json +++ b/www/common/translations/messages.fr.json @@ -1155,7 +1155,6 @@ "calendar_hours": "Heures", "calendar_minutes": "Minutes", "calendar_allDay": "Journée entière", - "calendar_location": "Lieu : {0}", "calendar_loc": "Lieu", "calendar_title": "Titre", "calendar_update": "Modifier", @@ -1671,13 +1670,16 @@ "calendar_rec_change_first": "Déplacer le premier événement répété vers un autre calendrier. Tous les autres événements seront aussi déplacés.", "calendar_desc": "Description", "sso_register_description": "S'enregistrer avec", - "calendar_description": "Description:{0}{1}", + "calendar_description": "Description : {0}{1}", "ssoauth_header": "Mot de passe CryptPad", "ssoauth_form_hint_login": "Veuillez entrer votre mot de passe CryptPad", "ssoauth_form_hint_register": "Ajouter un mot de passe CryptPad pour plus de sécurité ou laisser vide et continuer. Si vous n'ajoutez pas de mot de passe, les clés protégeant vos données seront accessibles aux administrateur·ices de l'instance.", "calendar_rec_change": "Déplacer un événement répété vers un autre calendrier. Vous pouvez appliquer ce changement seulement pour cet événement ou pour tous les événements répétés.", "sso_login_description": "Se connecter avec", "duplicate": "Dupliquer", - "kanban_showTags": "Voir toutes les étiquettes", - "kanban_hideTags": "Voir moins d'étiquettes" + "kanban_showTags": "Voir tous les mots-clés", + "kanban_hideTags": "Voir moins de mots-clés", + "admin_forcemfaTitle": "Authentification à deux facteurs obligatoire", + "admin_forcemfaHint": "Tous les utilisateurs de cette instance seront obligés d'activer l'authentification à deux facteurs pour se connecter à leur compte.", + "loading_mfa_required": "L'authentification à deux facteurs est requise pour cette instance. Veuillez mettre à jour votre compte en utilisant une application d'authentification et le formulaire ci-dessous." } diff --git a/www/common/translations/messages.id.json b/www/common/translations/messages.id.json index 3a927a872..334172693 100644 --- a/www/common/translations/messages.id.json +++ b/www/common/translations/messages.id.json @@ -915,7 +915,6 @@ "calendar_default": "Kalender saya", "calendar_new": "Kalender baru", "calendar_newEvent": "Acara baru", - "calendar_location": "Lokasi: {0}", "calendar_allDay": "Seharian", "reminder_inProgressAllDay": "Hari ini: {0}", "admin_supportPrivButton": "Tampilkan kunci", diff --git a/www/common/translations/messages.it.json b/www/common/translations/messages.it.json index 099b208fc..cf769477a 100644 --- a/www/common/translations/messages.it.json +++ b/www/common/translations/messages.it.json @@ -1098,7 +1098,6 @@ "broadcast_end": "Termina", "broadcast_maintenance": "Una manutenzione è programmata tra {0} e {1}. CryptPad potrebbe non essere disponibile in quel lasso di tempo.", "admin_surveyTitle": "Sondaggio", - "calendar_location": "Posizione: {0}", "admin_surveyButton": "Salva sondaggio", "admin_surveyActive": "Apri sondaggio", "calendar_minutes": "Minuti", diff --git a/www/common/translations/messages.ja.json b/www/common/translations/messages.ja.json index 1c139d00e..f3239c85b 100644 --- a/www/common/translations/messages.ja.json +++ b/www/common/translations/messages.ja.json @@ -940,7 +940,6 @@ "calendar_hours": "時間", "calendar_minutes": "分", "calendar_allDay": "全日", - "calendar_location": "場所: {0}", "calendar_loc": "場所", "calendar_title": "タイトル", "calendar_update": "更新", diff --git a/www/common/translations/messages.nl.json b/www/common/translations/messages.nl.json index b8cc5dd72..022193059 100644 --- a/www/common/translations/messages.nl.json +++ b/www/common/translations/messages.nl.json @@ -522,7 +522,6 @@ "calendar_import": "Voeg toe aan mijn agenda's", "calendar_dateTimeRange": "{0} {1} - {2}", "calendar_title": "Titel", - "calendar_location": "Plaats: {0}", "calendar_minutes": "Minuten", "calendar_addNotification": "Voeg herinnering toe", "register_registrationIsClosed": "Registratie is gesloten.", diff --git a/www/common/translations/messages.pl.json b/www/common/translations/messages.pl.json index 06a49e655..f86939792 100644 --- a/www/common/translations/messages.pl.json +++ b/www/common/translations/messages.pl.json @@ -899,7 +899,6 @@ "calendar_hours": "Godziny", "calendar_minutes": "Minuty", "calendar_allDay": "Cały dzień", - "calendar_location": "Lokalizacja: {0}", "calendar_loc": "Lokalizacja", "calendar_title": "Tytuł", "calendar_update": "Zaktualizuj", diff --git a/www/common/translations/messages.pt-br.json b/www/common/translations/messages.pt-br.json index 7159d8b03..8026159d8 100644 --- a/www/common/translations/messages.pt-br.json +++ b/www/common/translations/messages.pt-br.json @@ -1235,7 +1235,6 @@ "calendar_hours": "Horas", "calendar_minutes": "Minutos", "calendar_allDay": "O dia todo", - "calendar_location": "Localização: {0}", "calendar_loc": "Local", "calendar_title": "Título", "calendar_update": "Atualizar", diff --git a/www/common/translations/messages.pt-pt.json b/www/common/translations/messages.pt-pt.json index 4be7f68b0..e6c82a531 100644 --- a/www/common/translations/messages.pt-pt.json +++ b/www/common/translations/messages.pt-pt.json @@ -1234,7 +1234,6 @@ "calendar_hours": "Horas", "calendar_minutes": "Minutos", "calendar_allDay": "O dia todo", - "calendar_location": "Localização: {0}", "calendar_loc": "Local", "calendar_title": "Título", "calendar_update": "Atualizar", diff --git a/www/common/translations/messages.ru.json b/www/common/translations/messages.ru.json index 6ee081441..355ff6335 100644 --- a/www/common/translations/messages.ru.json +++ b/www/common/translations/messages.ru.json @@ -1337,7 +1337,6 @@ "calendar_hours": "Часа(ов)", "calendar_minutes": "Минут(ы)", "calendar_allDay": "Весь день", - "calendar_location": "Место: {0}", "calendar_loc": "Место", "calendar_title": "Заголовок", "calendar_update": "Обновить", diff --git a/www/common/translations/messages.sv.json b/www/common/translations/messages.sv.json index e8c903b0c..6f3aafa9b 100644 --- a/www/common/translations/messages.sv.json +++ b/www/common/translations/messages.sv.json @@ -650,7 +650,6 @@ "calendar_month": "Månad", "calendar_title": "Rubrik", "calendar_noNotification": "Ingen", - "calendar_location": "Plats: {0}", "settings_notifCalendarTitle": "Kalenderaviseringar", "mediatag_defaultImageName": "bild", "share_formAuditor": "Granskare", diff --git a/www/common/translations/messages.uk.json b/www/common/translations/messages.uk.json index a0b3f28ec..914fe77e5 100644 --- a/www/common/translations/messages.uk.json +++ b/www/common/translations/messages.uk.json @@ -1031,7 +1031,6 @@ "calendar_hours": "Години", "calendar_minutes": "Хвилини", "calendar_allDay": "Весь день", - "calendar_location": "Місце: {0}", "calendar_loc": "Місце", "calendar_title": "Заголовок", "calendar_update": "Оновити", diff --git a/www/common/translations/messages.zh.json b/www/common/translations/messages.zh.json index 5f36f370a..604e64ab9 100644 --- a/www/common/translations/messages.zh.json +++ b/www/common/translations/messages.zh.json @@ -1236,7 +1236,6 @@ "calendar_hours": "小时", "calendar_minutes": "分钟", "calendar_allDay": "全天", - "calendar_location": "位置:{0}", "calendar_loc": "地点", "calendar_title": "标题", "calendar_update": "更新", diff --git a/www/form/inner.js b/www/form/inner.js index 5cd9e1449..5b6e7eb14 100644 --- a/www/form/inner.js +++ b/www/form/inner.js @@ -2065,6 +2065,7 @@ define([ var tag = h('input'); var picker = Flatpickr(tag, { + disableMobile: true, enableTime: true, time_24hr: is24h, dateFormat: dateFormat, @@ -3295,7 +3296,7 @@ define([ }); sorted.forEach(function (uid) { var answer = answers[uid]; - + var viewOnly = content.answers.cantEdit || APP.isClosed; var action = h(viewOnly ? 'button.btn.btn-secondary' : 'button.btn.btn-primary', [ @@ -4109,9 +4110,27 @@ define([ idx = obj.idx; _uid = Util.uid(); + var opts = Util.clone(content.form[uid].opts); + if (type === 'multiradio' || type === 'multicheck') { + var itemKeys = Util.getKeysArray(content.form[uid].opts.items.length); + var valueKeys = Util.getKeysArray(content.form[uid].opts.values.length); + opts.items = itemKeys.map(function (i) { + return { + uid: Util.uid(), + v: content.form[uid].opts.items[i].v + }; + }); + opts.values = valueKeys.map(function (i) { + return { + uid: Util.uid(), + v: content.form[uid].opts.values[i].v + }; + }); + } + content.form[_uid] = { q: content.form[uid].q, - opts: content.form[uid].opts, + opts: opts, type: type, }; diff --git a/www/kanban/inner.js b/www/kanban/inner.js index 302b099dd..966d5db9a 100644 --- a/www/kanban/inner.js +++ b/www/kanban/inner.js @@ -1137,6 +1137,33 @@ define([ if (framework.isReadOnly() || framework.isLocked()) { $container.addClass('cp-app-readonly'); } + + var cleanData = function (boards) { + if (typeof(boards) !== "object") { return; } + var items = boards.items || {}; + var data = boards.data || {}; + var list = boards.list || []; + + // Remove duplicate boards + list = boards.list = Util.deduplicateString(list); + + Object.keys(data).forEach(function (id) { + if (list.indexOf(Number(id)) === -1) { + list.push(Number(id)); + } + // Remove duplicate items + var b = data[id]; + b.item = Util.deduplicateString(b.item || []); + }); + Object.keys(items).forEach(function (eid) { + var exists = Object.keys(data).some(function (id) { + return (data[id].item || []).indexOf(Number(eid)) !== -1; + }); + if (!exists) { delete items[eid]; } + }); + framework.localChange(); + }; + framework.setFileImporter({accept: ['.json', 'application/json']}, function (content /*, file */) { var parsed; try { parsed = JSON.parse(content); } @@ -1150,6 +1177,8 @@ define([ }); framework.setFileExporter('.json', function () { + var content = kanban.getBoardsJSON(); + cleanData(content); return new Blob([JSON.stringify(kanban.getBoardsJSON(), 0, 2)], { type: 'application/json', }); @@ -1291,32 +1320,6 @@ define([ }; }); - var cleanData = function (boards) { - if (typeof(boards) !== "object") { return; } - var items = boards.items || {}; - var data = boards.data || {}; - var list = boards.list || []; - - // Remove duplicate boards - list = boards.list = Util.deduplicateString(list); - - Object.keys(data).forEach(function (id) { - if (list.indexOf(Number(id)) === -1) { - list.push(Number(id)); - } - // Remove duplicate items - var b = data[id]; - b.item = Util.deduplicateString(b.item || []); - }); - Object.keys(items).forEach(function (eid) { - var exists = Object.keys(data).some(function (id) { - return (data[id].item || []).indexOf(Number(eid)) !== -1; - }); - if (!exists) { delete items[eid]; } - }); - framework.localChange(); - }; - framework.onReady(function () { $("#cp-app-kanban-content").focus(); var content = kanban.getBoardsJSON();