diff --git a/.jshintignore b/.jshintignore index 2ac398427..d3c96b2c0 100644 --- a/.jshintignore +++ b/.jshintignore @@ -8,7 +8,6 @@ www/common/onlyoffice/v2* www/common/onlyoffice/v4 www/common/onlyoffice/v5 -server.js www/scratch www/accounts www/lib diff --git a/customize.dist/src/less2/include/alertify.less b/customize.dist/src/less2/include/alertify.less index 5bf91f351..3cd4c0318 100644 --- a/customize.dist/src/less2/include/alertify.less +++ b/customize.dist/src/less2/include/alertify.less @@ -183,6 +183,11 @@ } } } + .cp-dropdown-content { + a { + text-decoration: none; + } + } } .cp-alertify-type-container { overflow: visible !important; diff --git a/customize.dist/src/less2/include/checkmark.less b/customize.dist/src/less2/include/checkmark.less index f41f1bcb8..61a707299 100644 --- a/customize.dist/src/less2/include/checkmark.less +++ b/customize.dist/src/less2/include/checkmark.less @@ -120,6 +120,7 @@ border-width: 0 @checkmark-width @checkmark-width 0; border-width: 0 var(--checkmark-width) var(--checkmark-width) 0; position: absolute; + box-sizing: border-box; } &:focus { box-shadow: 0px 0px 5px @cp_checkmark-back1; diff --git a/customize.dist/src/less2/include/dropdown.less b/customize.dist/src/less2/include/dropdown.less index 5f55cb3e7..f6304db05 100644 --- a/customize.dist/src/less2/include/dropdown.less +++ b/customize.dist/src/less2/include/dropdown.less @@ -48,7 +48,7 @@ button { .fa-caret-down { - margin-right: 1em !important; + margin-right: 0.5em !important; } * { .tools_unselectable(); diff --git a/customize.dist/src/less2/include/forms.less b/customize.dist/src/less2/include/forms.less index fa51a3c8c..c5db3861e 100644 --- a/customize.dist/src/less2/include/forms.less +++ b/customize.dist/src/less2/include/forms.less @@ -91,7 +91,7 @@ height: 100%; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px; - background-color: @cp_buttons-primary; + background-color: @cp_buttons-default-color; &.danger, &.btn-danger, &.danger-alt, &.btn-danger-alt { background-color: @cp_buttons-red; } @@ -327,6 +327,9 @@ fill: @cryptpad_text_col; } } + .flatpickr-monthDropdown-month { + background: @cp_flatpickr-bg; + } } .flatpickr-current-month { span.cur-month:hover { diff --git a/customize.dist/src/less2/include/icon-colors.less b/customize.dist/src/less2/include/icon-colors.less index 0d7ecb121..112d2261b 100644 --- a/customize.dist/src/less2/include/icon-colors.less +++ b/customize.dist/src/less2/include/icon-colors.less @@ -5,6 +5,7 @@ & { each(@colortheme_apps, { + button .cp-icon-color-@{key}, .cp-icon-color-@{key} { color: @value; } }); diff --git a/customize.dist/src/less2/include/notifications.less b/customize.dist/src/less2/include/notifications.less index f3bc8c0b4..0d58f4dc3 100644 --- a/customize.dist/src/less2/include/notifications.less +++ b/customize.dist/src/less2/include/notifications.less @@ -27,14 +27,16 @@ color: @cryptpad_color_red; } } - .cp-avatar { - .avatar_main(30px); - padding: 0 5px; + .cp-reminder, .cp-avatar { cursor: pointer; &:hover { background-color: @cp_dropdown-bg-hover; } } + .cp-avatar { + .avatar_main(30px); + padding: 0 5px; + } .cp-notification-content { flex: 1; align-items: stretch; diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index fecd471ee..b1eb49887 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -967,6 +967,9 @@ } } } + .cp-toolbar-dropdown-nowrap { + white-space: nowrap; + } .cp-toolbar-bottom { color: @cp_toolbar-bottom-fg; display: inline-flex; @@ -998,11 +1001,15 @@ .fa, .cptools { margin-right: 5px; } + .cp-dropdown-button-title .cp-icon { + margin-left: 5px; + } &:hover { background-color: fade(@cp_toolbar-bottom-bg, 70%); } } .cp-toolbar-bottom-left > button, + .cp-toolbar-bottom-left > span > button, .cp-toolbar-bottom-mid > button, .cp-toolbar-bottom-right > button, .cp-toolbar-bottom-right > span > button { @@ -1070,7 +1077,7 @@ .cp-toolbar-name, .cp-button-name { display: none; } - i { + i, span { margin-right: 0; } } diff --git a/lib/env.js b/lib/env.js index bb32f88c0..8824c347f 100644 --- a/lib/env.js +++ b/lib/env.js @@ -11,6 +11,7 @@ const Core = require("./commands/core"); const Quota = require("./commands/quota"); const Util = require("./common-util"); const Package = require("../package.json"); +const Path = require("path"); var canonicalizeOrigin = function (s) { if (typeof(s) === 'undefined') { return; } @@ -296,7 +297,7 @@ module.exports.create = function (config) { var paths = Env.paths; var keyOrDefaultString = function (key, def) { - return typeof(config[key]) === 'string'? config[key]: def; + return Path.resolve(typeof(config[key]) === 'string'? config[key]: def); }; Env.incrementBytesWritten = function (n) { diff --git a/lib/metadata.js b/lib/metadata.js index f61d2d509..495c33ff9 100644 --- a/lib/metadata.js +++ b/lib/metadata.js @@ -405,6 +405,7 @@ Meta.createLineHandler = function (ref, errorHandler) { ref.meta = {}; ref.index = 0; ref.logged = {}; + var overwritten = false; return function (err, line) { if (err) { @@ -449,6 +450,8 @@ Meta.createLineHandler = function (ref, errorHandler) { // Thus, accept both the first and second lines you process as valid initial state // preferring the second if it exists if (index < 2 && line && typeof(line) === 'object') { + if (overwritten) { return; } // hack to avoid overwriting metadata a second time + overwritten = true; // special case! ref.meta = line; return; diff --git a/lib/storage/file.js b/lib/storage/file.js index ef3d2ddfe..f074aad4e 100644 --- a/lib/storage/file.js +++ b/lib/storage/file.js @@ -1144,8 +1144,8 @@ module.exports.create = function (conf, _cb) { var cb = Util.once(Util.mkAsync(_cb)); var env = { - root: conf.filePath || './datastore', - archiveRoot: conf.archivePath || './data/archive', + root: Path.resolve(conf.filePath || './datastore'), + archiveRoot: Path.resolve(conf.archivePath || './data/archive'), // supply a volumeId if you want a store to archive channels to and from // to its own subpath within the archive directory volumeId: conf.volumeId || 'datastore', diff --git a/server.js b/server.js index cbed35cf7..9c4215392 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,5 @@ /* - globals require console + globals process */ var Express = require('express'); var Http = require('http'); @@ -8,7 +8,6 @@ var Path = require("path"); var nThen = require("nthen"); var Util = require("./lib/common-util"); var Default = require("./lib/defaults"); -var Keys = require("./lib/keys"); var config = require("./lib/load-config"); var Env = require("./lib/env").create(config); @@ -116,16 +115,17 @@ app.head(/^\/common\/feedback\.html/, function (req, res, next) { }); }()); +const serveStatic = Express.static(Env.paths.blob, { + setHeaders: function (res) { + res.set('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders); + res.set('Access-Control-Allow-Headers', 'Content-Length'); + res.set('Access-Control-Expose-Headers', 'Content-Length'); + } +}); + app.use('/blob', function (req, res, next) { if (req.method === 'HEAD') { - Express.static(Path.join(__dirname, Env.paths.blob), { - setHeaders: function (res, path, stat) { - res.set('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders); - res.set('Access-Control-Allow-Headers', 'Content-Length'); - res.set('Access-Control-Expose-Headers', 'Content-Length'); - } - })(req, res, next); - return; + return void serveStatic(req, res, next); } next(); }); @@ -150,32 +150,33 @@ app.use(function (req, res, next) { // serve custom app content from the customize directory // useful for testing pages customized with opengraph data -app.use(Express.static(__dirname + '/customize/www')); -app.use(Express.static(__dirname + '/www')); +app.use(Express.static(Path.resolve('customize/www'))); +app.use(Express.static(Path.resolve('www'))); // FIXME I think this is a regression caused by a recent PR // correct this hack without breaking the contributor's intended behaviour. var mainPages = config.mainPages || Default.mainPages(); var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$'); -app.get(mainPagePattern, Express.static(__dirname + '/customize')); -app.get(mainPagePattern, Express.static(__dirname + '/customize.dist')); +app.get(mainPagePattern, Express.static(Path.resolve('customize'))); +app.get(mainPagePattern, Express.static(Path.resolve('customize.dist'))); -app.use("/blob", Express.static(Path.join(__dirname, Env.paths.blob), { +app.use("/blob", Express.static(Env.paths.blob, { maxAge: Env.DEV_MODE? "0d": "365d" })); -app.use("/datastore", Express.static(Path.join(__dirname, Env.paths.data), { +app.use("/datastore", Express.static(Env.paths.data, { maxAge: "0d" })); -app.use("/block", Express.static(Path.join(__dirname, Env.paths.block), { + +app.use("/block", Express.static(Env.paths.block, { maxAge: "0d", })); -app.use("/customize", Express.static(__dirname + '/customize')); -app.use("/customize", Express.static(__dirname + '/customize.dist')); -app.use("/customize.dist", Express.static(__dirname + '/customize.dist')); -app.use(/^\/[^\/]*$/, Express.static('customize')); -app.use(/^\/[^\/]*$/, Express.static('customize.dist')); +app.use("/customize", Express.static(Path.resolve('customize'))); +app.use("/customize", Express.static(Path.resolve('customize.dist'))); +app.use("/customize.dist", Express.static(Path.resolve('customize.dist'))); +app.use(/^\/[^\/]*$/, Express.static(Path.resolve('customize'))); +app.use(/^\/[^\/]*$/, Express.static(Path.resolve('customize.dist'))); // if dev mode: never cache var cacheString = function () { @@ -216,7 +217,7 @@ var makeRouteCache = function (template, cacheName) { }; }; -var serveConfig = makeRouteCache(function (host) { +var serveConfig = makeRouteCache(function () { return [ 'define(function(){', 'return ' + JSON.stringify({ @@ -244,10 +245,10 @@ var serveConfig = makeRouteCache(function (host) { accounts_api: Env.accounts_api, }, null, '\t'), '});' - ].join(';\n') + ].join(';\n'); }, 'configCache'); -var serveBroadcast = makeRouteCache(function (host) { +var serveBroadcast = makeRouteCache(function () { var maintenance = Env.maintenance; if (maintenance && maintenance.end && maintenance.end < (+new Date())) { maintenance = undefined; @@ -260,21 +261,21 @@ var serveBroadcast = makeRouteCache(function (host) { maintenance: maintenance }, null, '\t'), '});' - ].join(';\n') + ].join(';\n'); }, 'broadcastCache'); app.get('/api/config', serveConfig); app.get('/api/broadcast', serveBroadcast); -var define = function (obj) { +var defineBlock = function (obj) { return `define(function (){ return ${JSON.stringify(obj, null, '\t')}; -});` +});`; }; app.get('/api/instance', function (req, res) { // XXX use caching? res.setHeader('Content-Type', 'text/javascript'); - res.send(define({ + res.send(defineBlock({ name: Env.instanceName, description: Env.instanceDescription, location: Env.instanceJurisdiction, @@ -282,10 +283,10 @@ app.get('/api/instance', function (req, res) { // XXX use caching? })); }); -var four04_path = Path.resolve(__dirname + '/customize.dist/404.html'); -var fivehundred_path = Path.resolve(__dirname + '/customize.dist/500.html'); -var custom_four04_path = Path.resolve(__dirname + '/customize/404.html'); -var custom_fivehundred_path = Path.resolve(__dirname + '/customize/500.html'); +var four04_path = Path.resolve('customize.dist/404.html'); +var fivehundred_path = Path.resolve('customize.dist/500.html'); +var custom_four04_path = Path.resolve('customize/404.html'); +var custom_fivehundred_path = Path.resolve('/customize/500.html'); var send404 = function (res, path) { if (!path && path !== four04_path) { path = four04_path; } @@ -321,7 +322,7 @@ app.get('/api/updatequota', function (req, res) { }); }); -app.get('/api/profiling', function (req, res, next) { +app.get('/api/profiling', function (req, res) { if (!Env.enableProfiling) { return void send404(res); } res.setHeader('Content-Type', 'text/javascript'); res.send(JSON.stringify({ @@ -329,13 +330,13 @@ app.get('/api/profiling', function (req, res, next) { })); }); -app.use(function (req, res, next) { +app.use(function (req, res) { res.status(404); send404(res, custom_four04_path); }); // default message for thrown errors in ExpressJS routes -app.use(function (err, req, res, next) { +app.use(function (err, req, res) { Env.Log.error('EXPRESSJS_ROUTING', { error: err.stack || err, }); @@ -346,7 +347,7 @@ app.use(function (err, req, res, next) { var httpServer = Env.httpServer = Http.createServer(app); nThen(function (w) { - Fs.exists(__dirname + "/customize", w(function (e) { + Fs.exists(Path.resolve("customize"), w(function (e) { if (e) { return; } console.log("CryptPad is customizable, see customize.dist/readme.md for details"); })); @@ -377,7 +378,7 @@ nThen(function (w) { Http.createServer(app).listen(Env.httpSafePort, Env.httpAddress, w()); } }).nThen(function () { - var wsConfig = { server: httpServer }; + //var wsConfig = { server: httpServer }; // Initialize logging then start the API server require("./lib/log").create(config, function (_log) { diff --git a/www/calendar/app-calendar.less b/www/calendar/app-calendar.less index 87f37dcb1..e8b7be8ed 100644 --- a/www/calendar/app-calendar.less +++ b/www/calendar/app-calendar.less @@ -17,6 +17,9 @@ .cp-small { display: none; } } + .flatpickr-calendar { + z-index: 100001 !important; // Alertify is 100000 + } #cp-sidebarlayout-container #cp-sidebarlayout-rightside { padding: 0; & > div { @@ -101,6 +104,21 @@ color: @cryptpad_text_col !important; } } + + .tui-full-calendar-floating-layer.cp-calendar-popup-flex { + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + justify-content: center !important; + align-items: center !important; + .tui-full-calendar-popup { + width: 540px !important; + } + } + #tui-full-calendar-popup-arrow { + display: none !important; + } } .tui-full-calendar-timegrid-timezone { background-color: @cp_sidebar-right-bg !important; @@ -116,13 +134,30 @@ border-color: @cp_calendar-border !important; } + .tui-full-calendar-popup { + border-radius: @variables_radius_L; + } .tui-full-calendar-popup-container { background: @cp_flatpickr-bg; color: @cryptpad_text_col; - border-radius: @variables_radius; + border-radius: @variables_radius_L; + font-weight: normal; .tui-full-calendar-icon:not(.tui-full-calendar-calendar-dot):not(.tui-full-calendar-dropdown-arrow):not(.tui-full-calendar-ic-checkbox) { display: none; } + .tui-full-calendar-popup-detail-item { + a { + color: @cryptpad_color_link; + text-decoration: underline; + } + } + .tui-full-calendar-section-button-save { + height: 40px; + .btn-primary { // Update button + margin-right: 0px; + } + } + } li.tui-full-calendar-popup-section-item { padding: 0 6px; @@ -186,6 +221,9 @@ width: 100%; height: 32px; border-radius: @variables_radius; + input[type="checkbox"].tui-full-calendar-checkbox-square:checked + span { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAABbmlDQ1BpY2MAACiRdZHNK0RRGMZ/Zoh8NIqFNItZDFmMEiVLxsJmkgZlsLn3zpe6c93uvZNkq2wspizExtfCf8BW2VJKkZKs/AG+Npqu97hqJM7t3PfXc87zds5zIJQyjZJbPwAly3PSE8nYXGY+1vhEMy20EyaqGa49NjWV4t/xfkOdqtf9qtf/+/4cLdmca0Bdk/CwYTue8KhwasWzFW8KdxpFLSu8L5xw5IDCF0rXA35UXAj4VbEzkx6HkOoZK/xg/QcbRack3CccL5ll4/s86iatOWt2Wmq3zCguaSZIEkOnzBImHv1SLcnsb9/Al2+SZfEY8rdZxRFHgaJ4E6KWpWtOal70nHwmqyr333m6+aHBoHtrEhoefP+lBxq3oFrx/Y8D368eQvgezqyaf1lyGnkTvVLT4nsQWYeT85qmb8PpBnTd2ZqjfUlhmaF8Hp6PoS0DHVfQvBBk9b3O0S3MrMkTXcLOLvTK/sjiJ6CLZ94KREMsAAAACXBIWXMAAAsSAAALEgHS3X78AAABHUlEQVQoFWNkaP//n4EMwESGHrAW6mtkZvz3G2g03BsBagz/nRUQ7sNp49//TJ+Byv6BlMbrMjCsC2Jg3BbKwOAgB9GMolGAg4GBESIOIrmA+A9I03xvFHGwCrhGkDOe5TAw9LvAFbEn6jEwwTT9BtodvJ7h/4FHYH0MLBCKgaHTgYGBE8jLN2FgYAJae+4FA8NcLwZWkAtAmoLWMTBsuYNwECMsHrVEGBj2RzEwiIEciATANgE1bb6DJAgMNLhTr71hYHBcxsDw+htCAQ5NIAU/4RpBPKjmf2++MfwHaQpZD7cJyEMB3+BORRL+rybE8FOEk4Hj2FO4KMgdrFAMitun2DTCVSMxYAkBFFYg9j94qCIpwsZEil5wyDIDAAXIUsnSKmq7AAAAAElFTkSuQmCC); + } .tui-full-calendar-ic-checkbox { margin-left: 5px; border-radius: 2px; @@ -196,6 +234,7 @@ .tui-full-calendar-popup-detail { font: @colortheme_app-font; color: @cryptpad_text_col; + box-shadow: @cryptpad_ui_shadow; .tui-full-calendar-popup-container { padding-bottom: 17px; } @@ -203,28 +242,44 @@ font-size: 14px; } .tui-full-calendar-section-button { + margin-top: 10px; border: 0; display: flex; - align-items: center; + align-items: start; button { flex: 1; margin: 0; } } + .tui-full-calendar-popup-top-line { + border-radius: 10px 10px 0px 0px; + height: 10px; + } .tui-full-calendar-popup-vertical-line { visibility: hidden; width: 10px; } } + .cp-recurrence-label, .cp-notif-label { + color: @cryptpad_text_col; + margin-right: 1rem; + i { + margin-right: 0.5rem; + } + } + + .cp-calendar-recurrence-container { + margin-top: 1rem; + .cp-calendar-rec-translated-str { + margin-top: 0.5rem; + } + } + .cp-calendar-add-notif { flex-flow: column; align-items: baseline !important; - margin: 10px 0; - .cp-notif-label { - color: @cp_sidebar-hint; - margin-right: 20px; - } + margin: 1rem 0; * { font-size: @colortheme_app-font-size; font-weight: normal; @@ -234,34 +289,58 @@ } .cp-calendar-notif-list-container { margin-bottom: 10px; + .cp-notif-label { + margin-top: 0.5em; + } } .cp-calendar-notif-list { display: flex; flex-flow: column; .cp-notif-entry { margin-bottom: 2px; + border-radius: @variables_radius; + background-color: fade(@cryptpad_text_col, 10%); + padding: 0.25rem; .cp-notif-value { width: 170px; display: inline-flex; + line-height: 30px; + vertical-align: middle; .cp-before { flex: 1; min-width: 0; } } span:not(:last-child) { - margin-right: 5px; + margin: 0px 5px; } + .btn-danger-outline { + margin-right: 0px !important; + background-color: transparent; + color: @cryptpad_text_col; + border-color: @cryptpad_text_col; + &:hover { + color: @cp_buttons-red-color; + background-color: @cp_buttons-red; + border-color: @cp_buttons-red; + } + } } } .cp-notif-empty { display: none; + margin-bottom: 2px; + border-radius: @variables_radius; + background-color: fade(@cryptpad_text_col, 10%); + padding: 0.25rem 0.5rem; + line-height: 30px; } .cp-calendar-notif-list:empty ~ .cp-notif-empty { display: block; } .cp-calendar-notif-form { align-items: center; - margin-bottom: 20px; + // margin-bottom: 20px; input { width: 80px; margin-right: 5px; @@ -270,15 +349,120 @@ } .cp-calendar-close { + top: 17px; + right: 17px; height: auto; + margin-right: 0px; line-height: initial; border: 1px solid; &:not(:hover) { background: transparent; } } + } + .cp-calendar-rec-inline, .cp-calendar-rec-block { + &:not(:last-child) { + margin-bottom: 10px; + } + } + .cp-calendar-rec-inline { + display: flex; + flex-flow: row; + align-items: center; + & > *:not(:first-child) { margin-left: 5px; } + .cp-dropdown-container { + position: unset; + } + input[type="number"] { + width: 80px !important; + margin-bottom: 0 !important; + } + .cp-checkmark { + margin-right: 0.5rem; + } + } + .cp-calendar-rec-block { + .cp-calendar-rec-block-title { + margin-bottom: 0.5rem !important; + } + .cp-radio { + margin-bottom: 0.5rem; + } + input[type="radio"]:not(:checked) ~ .cp-checkmark-label { + input { + filter: grayscale(1); + } + } + .cp-checkmark-label { + & > *:not(:first-child) { margin-left: 5px; } + width: 100%; + //height: 26px; + display: flex; + align-items: center; + & > input { + margin-bottom: 0 !important; + } + input { + display: inline; + height: 24px !important; + padding: 0 5px !important; + } + input[type="text"] { + width: 200px !important; + } + input[type="number"] { + width: 80px !important; + margin-bottom: 0 !important; + } + } + } + #cp-calendar-rec-monthly-pick ~ .cp-checkmark-label { + display: flex; + align-items: center; + & > span { + margin-right: 20px; + } + } + button.cp-calendar-pick-el { + display: flex; + align-items: center; + justify-content: center; + &:not(:last-child) { + margin-right: 5px; + } + } + div.cp-calendar-weekly-pick { + button { + width: 50px; + } + } + div.cp-calendar-monthly-pick { + display: flex; + flex-flow: column; + & > div { + display: flex; + &:not(:last-child) { + margin-bottom: 5px; + } + button { + height: 25px; + width: 25px; + &.lastday { + width: 115px; + } + } + } + } + + .tui-full-calendar-ic-repeat-b { + display: none; + & ~ * { + display: none; + } + } + #cp-toolbar .cp-calendar-browse { display: flex; align-items: center; @@ -395,6 +579,7 @@ align-items: center; justify-content: center; border-radius: @variables_radius; + flex-shrink: 0; } &.cp-active { background-color: @cp_sidebar-left-item-bg; diff --git a/www/calendar/export.js b/www/calendar/export.js index 307abad33..694179a9f 100644 --- a/www/calendar/export.js +++ b/www/calendar/export.js @@ -2,7 +2,9 @@ // Calendars will be exported using this format instead of plain text. define([ '/customize/pages.js', -], function (Pages) { + '/common/common-util.js', + '/calendar/recurrence.js' +], function (Pages, Util, Rec) { var module = {}; var getICSDate = function (str) { @@ -57,60 +59,197 @@ define([ var data = content[uid]; // DTSTAMP: now... // UID: uid - var start, end; - if (data.isAllDay && data.startDay && data.endDay) { - start = "DTSTART;VALUE=DATE:" + getDate(data.startDay); - end = "DTEND;VALUE=DATE:" + getDate(data.endDay, true); - } else { - start = "DTSTART:"+getICSDate(data.start); - end = "DTEND:"+getICSDate(data.end); - } + var getDT = function (data) { + var start, end; + if (data.isAllDay) { + var startDate = new Date(data.start); + var endDate = new Date(data.end); + data.startDay = data.startDay || (startDate.getFullYear() + '-' + (startDate.getMonth()+1) + '-' + startDate.getDate()); + data.endDay = data.endDay || (endDate.getFullYear() + '-' + (endDate.getMonth()+1) + '-' + endDate.getDate()); + start = "DTSTART;VALUE=DATE:" + getDate(data.startDay); + end = "DTEND;VALUE=DATE:" + getDate(data.endDay, true); + } else { + start = "DTSTART:"+getICSDate(data.start); + end = "DTEND:"+getICSDate(data.end); + } + return { + start: start, + end: end + }; + }; - Array.prototype.push.apply(ICS, [ - 'BEGIN:VEVENT', - 'DTSTAMP:'+getICSDate(+new Date()), - 'UID:'+uid, - start, - end, - 'SUMMARY:'+ data.title, - 'LOCATION:'+ data.location, - ]); - - if (Array.isArray(data.reminders)) { - data.reminders.forEach(function (valueMin) { - var time = valueMin * 60; - var days = Math.floor(time / DAY); - time -= days * DAY; - var hours = Math.floor(time / HOUR); - time -= hours * HOUR; - var minutes = Math.floor(time / MINUTE); - time -= minutes * MINUTE; - var seconds = time; - - var str = "-P" + days + "D"; - if (hours || minutes || seconds) { - str += "T" + hours + "H" + minutes + "M" + seconds + "S"; + var getRRule = function (data) { + if (!data.recurrenceRule || !data.recurrenceRule.freq) { return; } + var r = data.recurrenceRule; + var rrule = "RRULE:"; + rrule += "FREQ="+r.freq.toUpperCase(); + Object.keys(r).forEach(function (k) { + if (k === "freq") { return; } + if (k === "by") { + Object.keys(r.by).forEach(function (_k) { + rrule += ";BY"+_k.toUpperCase()+"="+r.by[_k]; + }); + return; + } + rrule += ";"+k.toUpperCase()+"="+r[k]; + }); + return rrule; + }; + + + + var addEvent = function (arr, data, recId) { + var uid = data.id; + var dt = getDT(data); + var start = dt.start; + var end = dt.end; + var rrule = getRRule(data); + + Array.prototype.push.apply(arr, [ + 'BEGIN:VEVENT', + 'DTSTAMP:'+getICSDate(+new Date()), + 'UID:'+uid, + start, + end, + recId, + rrule, + 'SUMMARY:'+ data.title, + 'LOCATION:'+ data.location, + ].filter(Boolean)); + + if (Array.isArray(data.reminders)) { + data.reminders.forEach(function (valueMin) { + var time = valueMin * 60; + var days = Math.floor(time / DAY); + time -= days * DAY; + var hours = Math.floor(time / HOUR); + time -= hours * HOUR; + var minutes = Math.floor(time / MINUTE); + time -= minutes * MINUTE; + var seconds = time; + + var str = "-P" + days + "D"; + if (hours || minutes || seconds) { + str += "T" + hours + "H" + minutes + "M" + seconds + "S"; + } + Array.prototype.push.apply(arr, [ + 'BEGIN:VALARM', + 'ACTION:DISPLAY', + 'DESCRIPTION:This is an event reminder', + 'TRIGGER:'+str, + 'END:VALARM' + ]); + }); + } + + if (Array.isArray(data.cp_hidden)) { + Array.prototype.push.apply(arr, data.cp_hidden); + } + + arr.push('END:VEVENT'); + }; + + + var applyChanges = function (base, changes) { + var applyDiff = function (obj, k) { + var diff = obj[k]; // Diff is always compared to origin start/end + var d = new Date(base[k]); + d.setDate(d.getDate() + diff.d); + d.setHours(d.getHours() + diff.h); + d.setMinutes(d.getMinutes() + diff.m); + base[k] = +d; + }; + Object.keys(changes || {}).forEach(function (k) { + if (k === "start" || k === "end") { + return applyDiff(changes, k); + } + base[k] = changes[k]; + }); + }; + + var prev = data; + + // Check if we have "one-time" or "from date" updates. + // "One-time" updates will be added accordingly to the ICS specs + // "From date" updates will be added as new events and will add + // an "until" value to the initial event's RRULE + var toAdd = []; + if (data.recurrenceRule && data.recurrenceRule.freq && data.recUpdate) { + var ru = data.recUpdate; + var _all = {}; + var duration = data.end - data.start; + + var all = Rec.getAllOccurrences(data); // "false" if infinite + + Object.keys(ru.from || {}).forEach(function (d) { + if (!Object.keys(ru.from[d] || {}).length) { return; } + _all[d] = _all[d] || {}; + _all[d].from = ru.from[d]; + }); + Object.keys(ru.one || {}).forEach(function (d) { + if (!Object.keys(ru.one[d] || {}).length) { return; } + _all[d] = _all[d] || {}; + _all[d].one = ru.one[d]; + }); + Object.keys(_all).sort(function (a, b) { + return Number(a) - Number(b); + }).forEach(function (d) { + d = Number(d); + var r = prev.recurrenceRule; + + // This rule won't apply if we've reached "until" or "count" + var idx = all && all.indexOf(d); + if (all && idx === -1) { + // Make sure we don't have both count and until + if (all.length === r.count) { delete r.until; } + else { delete r.count; } + return; + } + + var ud = _all[d]; + + if (ud.from) { // "From" updates are not supported by ICS: make a new event + var _new = Util.clone(prev); + r.until = getICSDate(d - 1); // Stop previous recursion + delete r.count; + addEvent(ICS, prev, null); // Add previous event + Array.prototype.push.apply(ICS, toAdd); // Add individual updates + toAdd = []; + prev = _new; + if (all) { all = all.slice(idx); } + + // if we updated the recurrence rule, count is reset, nothing to do + // if we didn't update the recurrence, we need to fix the count + var _r = _new.recurrenceRule; + if (all && !ud.from.recurrenceRule && _r && _r.count) { + _r.count -= idx; + } + + prev.start = d; + prev.end = d + duration; + prev.id = Util.uid(); + applyChanges(prev, ud.from); + duration = prev.end - prev.start; + } + if (ud.one) { // Add update + var _one = Util.clone(prev); + _one.start = d; + _one.end = d + duration; + applyChanges(_one, ud.one); + var recId = "RECURRENCE-ID:"+getICSDate(+d); + delete _one.recurrenceRule; + addEvent(toAdd, _one, recId); // Add updated event } - Array.prototype.push.apply(ICS, [ - 'BEGIN:VALARM', - 'ACTION:DISPLAY', - 'DESCRIPTION:This is an event reminder', - 'TRIGGER:'+str, - 'END:VALARM' - ]); }); } - if (Array.isArray(data.cp_hidden)) { - Array.prototype.push.apply(ICS, data.cp_hidden); - } - - ICS.push('END:VEVENT'); + addEvent(ICS, prev); + Array.prototype.push.apply(ICS, toAdd); // Add individual updates }); ICS.push('END:VCALENDAR'); - return new Blob([ ICS.join('\n') ], { type: 'text/calendar;charset=utf-8' }); + return new Blob([ ICS.join('\r\n') ], { type: 'text/calendar;charset=utf-8' }); }; module.import = function (content, id, cb) { @@ -171,7 +310,7 @@ define([ } // Store other properties - var used = ['dtstart', 'dtend', 'uid', 'summary', 'location', 'dtstamp']; + var used = ['dtstart', 'dtend', 'uid', 'summary', 'location', 'dtstamp', 'rrule', 'recurrence-id']; var hidden = []; ev.getAllProperties().forEach(function (p) { if (used.indexOf(p.name) !== -1) { return; } @@ -192,8 +331,25 @@ define([ if (reminders.indexOf(minutes) === -1) { reminders.push(minutes); } }); + // Get recurrence rule + var rrule = ev.getFirstPropertyValue('rrule'); + var rec; + if (rrule && rrule.freq) { + rec = {}; + rec.freq = rrule.freq.toLowerCase(); + if (rrule.interval) { rec.interval = rrule.interval; } + if (rrule.count) { rec.count = rrule.count; } + if (Object.keys(rrule).includes('wkst')) { rec.wkst = (rrule.wkst + 6) % 7; } + if (rrule.until) { rec.until = +new Date(rrule.until); } + Object.keys(rrule.parts || {}).forEach(function (k) { + rec.by = rec.by || {}; + var _k = k.toLowerCase().slice(2); // "BYDAY" ==> "day" + rec.by[_k] = rrule.parts[k]; + }); + } + // Create event - res[uid] = { + var obj = { calendarId: id, id: uid, category: 'time', @@ -203,15 +359,48 @@ define([ start: start, end: end, reminders: reminders, - cp_hidden: hidden + cp_hidden: hidden, }; + if (rec) { obj.recurrenceRule = rec; } - if (!hidden.length) { delete res[uid].cp_hidden; } - if (!reminders.length) { delete res[uid].reminders; } + if (!hidden.length) { delete obj.cp_hidden; } + if (!reminders.length) { delete obj.reminders; } + var recId = ev.getFirstPropertyValue('recurrence-id'); + if (recId) { + setTimeout(function () { + if (!res[uid]) { return; } + var old = res[uid]; + var time = +new Date(recId); + var diff = {}; + var from = {}; + Object.keys(obj).forEach(function (k) { + if (JSON.stringify(old[k]) === JSON.stringify(obj[k])) { return; } + if (['start','end'].includes(k)) { + diff[k] = Rec.diffDate(old[k], obj[k]); + return; + } + if (k === "recurrenceRule") { + from[k] = obj[k]; + return; + } + diff[k] = obj[k]; + }); + old.recUpdate = old.recUpdate || {one:{},from:{}}; + if (Object.keys(from).length) { old.recUpdate.from[time] = from; } + if (Object.keys(diff).length) { old.recUpdate.one[time] = diff; } + }); + return; + } + + res[uid] = obj; }); - cb(null, res); + // setTimeout to make sure we call back after the "recurrence-id" setTimeout + // are called + setTimeout(function () { + cb(null, res); + }); }); }; diff --git a/www/calendar/inner.js b/www/calendar/inner.js index 7a2e53c76..b3e9754b1 100644 --- a/www/calendar/inner.js +++ b/www/calendar/inner.js @@ -17,6 +17,7 @@ define([ '/customize/application_config.js', '/lib/calendar/tui-calendar.min.js', '/calendar/export.js', + '/calendar/recurrence.js', '/lib/datepicker/flatpickr.js', '/common/inner/share.js', @@ -48,6 +49,7 @@ define([ AppConfig, Calendar, Export, + Rec, Flatpickr, Share, Access, Properties ) @@ -121,11 +123,21 @@ define([ return (brightness > 125) ? '#424242' : '#EEEEEE'; }; + var getDateLanguage = function () { + console.error(Messages._languageUsed); + try { + new Date().toLocaleDateString(Messages._languageUsed, { weekday: 'long' }); + return Messages._languageUsed; + } catch (e) { + return; + } + }; + var getWeekDays = function (large) { var baseDate = new Date(2017, 0, 1); // just a Sunday var weekDays = []; for(var i = 0; i < 7; i++) { - weekDays.push(baseDate.toLocaleDateString(undefined, { weekday: 'long' })); + weekDays.push(baseDate.toLocaleDateString(getDateLanguage(), { weekday: 'long' })); baseDate.setDate(baseDate.getDate() + 1); } if (!large) { @@ -134,6 +146,67 @@ define([ return weekDays.map(function (day) { return day.replace(/^./, function (str) { return str.toUpperCase(); }); }); }; + // Get week number in our calendar view + var ISO8601_week_no = function (dt) { + var tdt = new Date(dt.valueOf()); + var dayn = (dt.getDay() + 6) % 7; + tdt.setDate(tdt.getDate() - dayn + 3); + var firstThursday = tdt.valueOf(); + tdt.setMonth(0, 1); + if (tdt.getDay() !== 4) { + tdt.setMonth(0, 1 + ((4 - tdt.getDay()) + 7) % 7); + } + return 1 + Math.ceil((firstThursday - tdt) / 604800000); + }; + + var updateDateRange = function () { + var range = APP.calendar._renderRange; + var start = range.start._date.toLocaleDateString(); + var end = range.end._date.toLocaleDateString(); + var week = ISO8601_week_no(range.start._date); + var date = [ + h('b.cp-small', Messages._getKey('calendar_weekNumber', [week])), + h('b', start), + h('span', ' - '), + h('b', end), + ]; + if (APP.calendar._viewName === "day") { + date = h('b', start); + } else if (APP.calendar._viewName === "month") { + var month; + var mid = new Date(Math.floor(((+range.start._date) + (+range.end._date)) / 2)); + try { + month = mid.toLocaleString('default', { + month: 'long', + year:'numeric' + }); + month = month.replace(/^./, function (str) { return str.toUpperCase(); }); + date = h('b', month); + } catch (e) { + // Use same as week range: first day of month to last day of month + } + } + APP.toolbar.$bottomM.empty().append(h('div', date)); + }; + APP.moveToDate = function (time) { + var cal = APP.calendar; + if (!cal) { return; } + + // Move calendar to correct date + var d = new Date(time); + cal.setDate(d); + updateDateRange(); + + if (cal.getViewName() === 'month') { return; } + + // Scroll to correct time + setTimeout(function () { + var h = d.toLocaleTimeString('en-US', { hour12: 0, timeStyle: "short" }).slice(0,2); + h = Number(h) % 24; + var $h = $('.tui-full-calendar-timegrid-timezone .tui-full-calendar-timegrid-hour'); + try { $h.get(h).scrollIntoView(); } catch (e) { console.error(e); } + }); + }; var getCalendars = function () { var LOOKUP = {}; @@ -170,6 +243,8 @@ define([ }); }; var getSchedules = function () { + APP.recurringEvents = []; + APP.recurringDone = []; var s = []; var calendars = Object.keys(APP.calendars); if (APP.currentCalendar) { @@ -200,19 +275,36 @@ define([ if (c.readOnly) { obj.isReadOnly = true; } - s.push(data.content[uid]); + if (obj.recurrenceRule) { + APP.recurringEvents.push(obj); + } + s.push(Util.clone(data.content[uid])); }); }); return s; }; + + var applyUpdates = Rec.applyUpdates; + + var updateRecurring = function () {}; // Defined later var renderCalendar = function () { var cal = APP.calendar; if (!cal) { return; } - cal.clear(); - cal.setCalendars(getCalendars()); - cal.createSchedules(getSchedules(), true); - cal.render(); + try { + cal.clear(); + cal.setCalendars(getCalendars()); + cal.createSchedules(applyUpdates(getSchedules()), true); + cal.render(); + if (APP.initTime && APP.moveToDate) { + APP.moveToDate(APP.initTime); + delete APP.initTime; + } + Rec.resetCache(); + updateRecurring(); + } catch (e) { + console.error(e); + } }; var onCalendarUpdate = function (data) { var cal = APP.calendar; @@ -261,11 +353,21 @@ define([ monthGridHeaderExceed: function(hiddenSchedules) { return '' + Messages._getKey('calendar_more', [hiddenSchedules]) + ''; }, - popupEdit: function() { return Messages.poll_edit; }, - popupDelete: function() { return Messages.kanban_delete; }, + popupEdit: function(obj) { + APP.editModalData = obj.data && obj.data.root; + return Messages.poll_edit; + }, popupDetailLocation: function(schedule) { - // TODO detect url and create 'a' tag - return Messages._getKey('calendar_location', [Util.fixHTML(schedule.location)]); + var l = schedule.location; + var str = Util.fixHTML(l); + delete APP.nextLocationUid; + if (/^https?:\/\//.test(l)) { + var uid = "cp-link-"+Util.uid(); + str = `${str}`; + APP.nextLocationUid = uid; + } + + return Messages._getKey('calendar_location', [str]); }, popupIsAllDay: function() { return Messages.calendar_allDay; }, titlePlaceholder: function() { return Messages.calendar_title; }, @@ -767,47 +869,51 @@ define([ }; - var ISO8601_week_no = function (dt) { - var tdt = new Date(dt.valueOf()); - var dayn = (dt.getDay() + 6) % 7; - tdt.setDate(tdt.getDate() - dayn + 3); - var firstThursday = tdt.valueOf(); - tdt.setMonth(0, 1); - if (tdt.getDay() !== 4) { - tdt.setMonth(0, 1 + ((4 - tdt.getDay()) + 7) % 7); + var _updateRecurring = function () { + var cal = APP.calendar; + if (!cal) { return; } + + var range = APP.calendar._renderRange; + var startView = range.start._date; + var endView = range.end._date; + endView.setDate(endView.getDate() + 1); + + var midView = new Date(((+startView) + (+endView)) / 2); + + // We want to generate recurring events month per month. + // In "month" view, we may see up to 3 different months + // at the same time. + var startId = Rec.getMonthId(startView); + var midId = Rec.getMonthId(midView); + var endId = Rec.getMonthId(endView); + var todo = Util.deduplicateString([startId, midId, endId]); + todo = todo.filter(function (monthId) { + return !APP.recurringDone.includes(monthId); + }); + + var toAdd = Rec.getRecurring(todo, APP.recurringEvents); + + // Mark selected months as done + todo.forEach(function (monthId) { APP.recurringDone.push(monthId); }); + + cal.createSchedules(applyUpdates(toAdd)); + }; + updateRecurring = function () { + try { + _updateRecurring(); + } catch (e) { + console.error(e); } - return 1 + Math.ceil((firstThursday - tdt) / 604800000); }; - var updateDateRange = function () { - var range = APP.calendar._renderRange; - var start = range.start._date.toLocaleDateString(); - var end = range.end._date.toLocaleDateString(); - var week = ISO8601_week_no(range.start._date); - var date = [ - h('b.cp-small', Messages._getKey('calendar_weekNumber', [week])), - h('b', start), - h('span', ' - '), - h('b', end), - ]; - if (APP.calendar._viewName === "day") { - date = h('b', start); - } else if (APP.calendar._viewName === "month") { - var month; - var mid = new Date(Math.floor(((+range.start._date) + (+range.end._date)) / 2)); - try { - month = mid.toLocaleString('default', { - month: 'long', - year:'numeric' - }); - month = month.replace(/^./, function (str) { return str.toUpperCase(); }); - date = h('b', month); - } catch (e) { - // Use same as week range: first day of month to last day of month - } - } - APP.toolbar.$bottomM.empty().append(h('div', date)); - }; +/* +UPDATE A RECCURENT EVENT: +ICS ==> create a new event with the same UID and a RECURRENCE-ID field (with a value equals to the DTSTART of this recurring event) +*/ + + + var diffDate = Rec.diffDate; + var makeCalendar = function (view) { var store = window.cryptpadStore; @@ -857,12 +963,6 @@ define([ makeLeftside(cal, $(leftside)); cal.on('beforeCreateSchedule', function(event) { - // TODO Recurrence (later) - // On creation, select a recurrence rule (daily / weekly / monthly / more weird rules) - // then mark it under recurrence rule with a uid (the same for all the recurring events) - // ie: recurrenceRule: DAILY|{uid} - // Use template to hide "recurrenceRule" from the detailPopup or at least to use - // a non technical value var reminders = APP.notificationsEntries; var startDate = event.start._date; @@ -871,13 +971,14 @@ define([ var schedule = { id: Util.uid(), calendarId: event.calendarId, - title: Util.fixHTML(event.title), + title: event.title, category: "time", - location: Util.fixHTML(event.location), + location: event.location, start: +startDate, isAllDay: event.isAllDay, end: +endDate, reminders: reminders, + recurrenceRule: APP.recurrenceRule }; newEvent(schedule, function (err) { @@ -891,35 +992,146 @@ define([ cal.on('beforeUpdateSchedule', function(event) { var changes = event.changes || {}; delete changes.state; + if (changes.end) { changes.end = +new Date(changes.end._date); } if (changes.start) { changes.start = +new Date(changes.start._date); } var old = event.schedule; + var id = old.id.split('|')[0]; - var oldReminders = Util.find(APP.calendars, [old.calendarId, 'content', 'content', old.id, 'reminders']); - var reminders = APP.notificationsEntries; - if (JSONSortify(oldReminders || []) !== JSONSortify(reminders)) { - changes.reminders = reminders; + var originalEvent = Util.find(APP.calendars, [old.calendarId, 'content', 'content', id]); + + var ev = APP.calendar.getSchedule(old.id, old.calendarId); + var evOrig = APP.calendar.getSchedule(id, old.calendarId); + + var isOrigin = id === old.id; + var wasRecurrent = Boolean(originalEvent.recurrenceRule); + + if (event.calendar) { // Don't update reminders and recurrence with drag&drop event + var oldReminders = ev.raw.reminders || originalEvent.reminders; + var reminders = APP.notificationsEntries; + if (JSONSortify(oldReminders || []) !== JSONSortify(reminders)) { + changes.reminders = reminders; + } + + var oldRec = ev.recurrenceRule; + var rec = APP.recurrenceRule; + if (JSONSortify(oldRec || '') !== JSONSortify(rec)) { + changes.recurrenceRule = rec; + } } - updateEvent({ - ev: old, - changes: changes - }, function (err) { - if (err) { - console.error(err); - return void UI.warn(err); + + if (!event.triggerEventName || event.triggerEventName !== "click") { + APP.recurrenceRule = ev.recurrenceRule; + } + + var afterConfirm = function () { + var raw = (ev && ev.raw) || {}; + var rawData = { // Exact start and end of the selected event + start: raw.start || ev.start, + end: raw.end || ev.end, + isOrigin: isOrigin + }; + if (['one', 'from'].includes(APP.editType)) { + if (changes.start) { + changes.start = diffDate(raw.start || ev.start, changes.start); + } + if (changes.end) { + changes.end = diffDate(raw.end || ev.end, changes.end); + } } - cal.updateSchedule(old.id, old.calendarId, changes); + + old.id = id; + updateEvent({ + ev: old, + changes: changes, + rawData: rawData, + type: { + which: APP.editType, + when: raw.start || ev.start + } + }, function (err) { + if (err) { + console.error(err); + return void UI.warn(err); + } + //cal.updateSchedule(old.id, old.calendarId, changes); + }); + }; + + + // Confirm modal: select which recurring events to update + if (!Object.keys(changes).length) { return void afterConfirm(); } + if (!wasRecurrent) { return void afterConfirm(); } + + var list = ['one','from','all']; + if (isOrigin) { list = ['one', 'all']; } + if ((changes.start || changes.end) && !isOrigin) { + list = list.filter(function (item) { + return item !== "all"; + }); + } + + var radioEls = list.map(function (k, i) { + return UI.createRadio('cp-calendar-rec-edit', 'cp-calendar-rec-edit-'+k, + Messages['calendar_rec_edit_'+k], !i, {input:{ 'data-value':k }}); }); + var p = h('p', Messages.calendar_rec_edit); + var warn = h('div.alert.alert-warning'); + var content = h('div', [ + warn, + p, + radioEls + ]); + UI.confirm(content, function (yes) { + if (!yes) { return; } + var r = $(content).find('input[name="cp-calendar-rec-edit"]:checked') + .data('value'); + APP.editType = r; + afterConfirm(); + }); + $(content).closest('.alertify').on('mousedown', function (e) { + e.stopPropagation(); + }); + + var $p = $(p); + var $warn = $(warn); + var $radio = $(radioEls); + var recurrenceWarn = function () { + if (typeof(changes.recurrenceRule) === "undefined") { + $p.show(); + return $warn.hide(); + } + $warn.show(); + $p.hide(); + var val = $radio.find('input[name="cp-calendar-rec-edit"]:checked') + .data('value'); + + + if (!changes.recurrenceRule) { // Rule was deleted + if (!val || val === "all") { + return $warn.text(Messages._getKey('calendar_rec_warn_delall', [ + new Date(evOrig.start).toLocaleDateString() + ])); + } + return $warn.text(Messages.calendar_rec_warn_del); + } + if (!val || val === "all") { + return $warn.text(Messages._getKey('calendar_rec_warn_updateall', [ + new Date(evOrig.start).toLocaleDateString() + ])); + } + return $warn.text(Messages.calendar_rec_warn_update); + }; + recurrenceWarn(); + $radio.find('input[type="radio"]').on('change', recurrenceWarn); }); cal.on('beforeDeleteSchedule', function(event) { - var data = event.schedule; deleteEvent(event.schedule, function (err) { if (err) { console.error(err); return void UI.warn(err); } - cal.deleteSchedule(data.id, data.calendarId); }); }); @@ -930,7 +1142,6 @@ define([ }); updateDateRange(); - renderCalendar(); // Toolbar @@ -965,6 +1176,7 @@ define([ var mode = $(this).attr('data-value'); cal.changeView(mode); updateDateRange(); + updateRecurring(); store.put('calendarView', mode, function () {}); }); APP.toolbar.$bottomR.append($block); @@ -983,24 +1195,620 @@ define([ var goLeft = h('button.fa.fa-chevron-left'); var goRight = h('button.fa.fa-chevron-right'); var goToday = h('button', Messages.calendar_today); + var goDate = h('button.fa.fa-calendar'); $(goLeft).click(function () { cal.prev(); updateDateRange(); + updateRecurring(); }); $(goRight).click(function () { cal.next(); updateDateRange(); + updateRecurring(); }); $(goToday).click(function () { - cal.today(); + APP.moveToDate(+new Date()); + //cal.today(); updateDateRange(); + updateRecurring(); + }); + $(goDate).click(function () { + var f = Flatpickr(goDate, { + enableTime: false, + defaultDate: APP.calendar.getDate()._date, + //dateFormat: dateFormat, + onChange: function (date) { + date[0].setHours(12); + f.destroy(); + APP.moveToDate(+date[0]); + updateDateRange(); + updateRecurring(); + }, + onClose: function () { + setTimeout(f.destroy); + } + }); + f.open(); }); APP.toolbar.$bottomL.append(h('div.cp-calendar-browse', [ - goLeft, goToday, goRight + goLeft, goToday, goRight, goDate ])); }; + + var WEEKDAYS = getWeekDays(true); + var listItems = function (_arr) { + var arr = _arr.slice(); + if (arr.length === 1) { + return arr[0]; + } + var shift = function () { + var i = arr.shift(); + if (i === -1) { return Messages.calendar_month_last; } + return i; + }; + var str = shift(); + var i = 0; + while (arr.length > 1 && i < 367) { + str = Messages._getKey('calendar_list', [str, shift()]); + i++; + } + str = Messages._getKey('calendar_list_end', [str, shift()]); + return str; + }; + var translate = function (rule) { + var str = ""; + if (!rule || !rule.freq) { return; } + var tmp = new Date(); + + // Freq, interval + str = Messages._getKey('calendar_str_'+rule.freq, [rule.interval || 1]); + + var m = rule.by && rule.by.month; + var d = rule.by && rule.by.day; + var md = rule.by && rule.by.monthday; + + var ord = false; + if (d) { + d = d.map(function (str) { + var nth = str.slice(0, -2); + nth = (nth === '-1') ? 'last' : nth; + ord = Boolean(nth); + var day = str.slice(-2); + var n = Rec.DAYORDER.indexOf(day); + var dayStr = WEEKDAYS[n]; + if (nth) { return Messages['calendar_nth_'+nth] + " " + dayStr; } + return dayStr; + }); + } + if (m) { + m = m.map(function (n) { + tmp.setMonth(n-1); + return tmp.toLocaleDateString(getDateLanguage(), { month: 'long' }); + }); + } + + // Until / count + var end = ""; + if (rule.count) { + end += " " + Messages._getKey('calendar_str_for', [rule.count]); + } + if (rule.until) { + end += " " + Messages._getKey('calendar_str_until', [ + new Date(rule.until).toLocaleDateString(getDateLanguage(), { + month: "long", + day: "numeric", + year: "numeric" + }) + ]); + } + + var filters = []; + // nth day (of month) + if (rule.freq === "yearly" && m && m.length === 1 && d && d.length === 1 + && Object.keys(rule.by).length === 2) { + str += " " + Messages._getKey('calendar_str_nthdayofmonth', [ + d[0], + m[0] + ]); + } else if (rule.freq === "monthly" && d && d.length === 1 && ord + && Object.keys(rule.by).length === 1) { + str += " " + Messages._getKey('calendar_str_monthday', [ + d[0] + ]); + } else if (rule.freq === "monthly" && md && Object.keys(rule.by).length === 1) { + str += " " + Messages._getKey('calendar_str_monthday', [listItems(md)]); + } else if (rule.freq === "weekly" && d && Object.keys(rule.by).length === 1) { + str += " " + Messages._getKey('calendar_str_day', [listItems(d)]); + } else if (rule.by) { + filters = Object.keys(rule.by).map(function (k) { + var val = rule.by[k]; + if (k === "month") { val = m; } + if (k === "day") { val = d; } + return Messages._getKey('calendar_str_filter_'+k, [listItems(val)]); + }); + } + + str += end; + + return { + str: Messages._getKey('calendar_rec_every_date', [str]), + filters: filters + }; + }; + + var getMonthlyPattern = function (date, yearly) { + var d = new Date(+date); + var day = d.getDay(); + var monthday = d.getDate(); + + // Check nth day + var s = new Date(+d); + s.setDate(1); + while (s.getDay() !== day) { s.setDate(s.getDate() + 1); } + var nth = ((monthday - s.getDate()) / 7) + 1; + + // Check last day + var m = d.getMonth(); + d.setDate(d.getDate() + 7); + var last = d.getMonth() !== m; + + var dayCode = Rec.DAYORDER[day]; + var dayStr = WEEKDAYS[day]; + var monthStr = date.toLocaleDateString(getDateLanguage(), { month: 'long' }); + + var key = yearly ? "yearly" : "monthly"; + return { + nth: nth + dayCode, + str: Messages._getKey('calendar_rec_'+key+'_nth', [ + Messages['calendar_nth_'+nth], + dayStr, + monthStr + ]), + last: last ? '-1' + dayStr : undefined, + lastStr: Messages._getKey('calendar_rec_'+key+'_nth', [ + Messages['calendar_nth_last'], + dayStr, + monthStr + ]), + // Messages.calendar_rec_yearly_nth + // Messages.calendar_rec_monthly_nth + }; + }; + + var getRecurrenceInput = function (date) { + APP.recurrenceRule = ''; + + var obj = APP.editModalData; + if (obj.id) { // Edit mode, recover recurrence data + var cal = obj.selectedCal.id; + var calData = APP.calendars[cal]; + if (calData) { + var ev = APP.calendar.getSchedule(obj.id, cal); + APP.recurrenceRule = ev.recurrenceRule || ''; + } + } + var updatedOn = APP.recurrenceRule && APP.recurrenceRule._next; + if (updatedOn) { delete APP.recurrenceRule._next; } + APP.wasRecurrent = Boolean(APP.recurrenceRule); + +// XXX TEST +/* +APP.recurrenceRule = { + freq: 'yearly', + interval: 2, + count: 30, + until: 1924902000000, + by: { + month: [1, 3, 5, 7, 9, 11], + weekno: [1, 11, 21, 31, 41, 51], + day: ["MO","TU","WE","TH","FR"] + } +}; +*/ + + var basicStr = {}; + + var options = [{ + tag: 'a', + attributes: { + 'class': 'cp-calendar-recurrence', + 'data-value': '', + 'href': '#', + }, + content: Messages.calendar_rec_no + }]; + // Basic recurrence + ['daily', 'weekly', 'monthly', 'yearly'].forEach(function (rec) { + basicStr[rec] = JSONSortify({freq: rec}); + options.push({ + tag: 'a', + attributes: { + 'class': 'cp-calendar-recurrence', + 'data-value': basicStr[rec], + 'href': '#', + }, + content: Messages._getKey('calendar_rec_' + rec, [ + getWeekDays(true)[date.getDay()], + date.getDate(), + date.toLocaleDateString(getDateLanguage(), {month:"long", day:"2-digit"}) + ]) + }); + }); + // Weekdays / Weekend + var isWeekend = [0,6].includes(date.getDay()); + var weekValue = isWeekend ? ['SA', 'SU'] : ['MO', 'TU', 'WE', 'TH', 'FR']; + basicStr.days = JSONSortify({ + freq: 'daily', + by: { day: weekValue } + }); + options.push({ + tag: 'a', + attributes: { + 'class': 'cp-calendar-recurrence', + 'data-value': basicStr.days, + 'href': '#', + }, + content: Messages['calendar_rec_' + (isWeekend ? 'weekend' : 'weekdays')] + }); + // Custom + options.push({ + tag: 'a', + attributes: { + 'class': 'cp-calendar-recurrence', + 'data-value': 'custom', + 'href': '#', + }, + content: Messages.calendar_rec_custom + }); + + var dropdownConfig = { + text: Messages.calendar_rec_no, + options: options, // Entries displayed in the menu + isSelect: true, + common: common, + buttonCls: 'btn btn-secondary', + caretDown: true, + }; + var $block = UIElements.createDropdown(dropdownConfig); + + var translated = h('div.cp-calendar-rec-translated'); + var $translated = $(translated); + var _addTranslation = function () { + $translated.empty(); + + // Dropdown value + var recStr = JSONSortify(APP.recurrenceRule); + var set = Object.keys(basicStr).some(function (k) { + if (recStr === basicStr[k]) { + $block.setValue(basicStr[k]); + $translated.empty(); + return true; + } + }); + if (set) { return; } + $block.setValue(APP.recurrenceRule ? 'custom' : ''); + + // Text value + + var ruleObj = translate(APP.recurrenceRule); + if (!ruleObj || !ruleObj.str) { return; } + $translated.append(h('div.cp-calendar-rec-translated-str', ruleObj.str)); + + if (!ruleObj.filters || !Array.isArray(ruleObj.filters) + || !ruleObj.filters.length) { return; } + var toAdd = []; + toAdd = ruleObj.filters.map(function (str) { + return h('li', str); + }); + $translated.append([ + h('div', Messages.calendar_str_filter), + h('ul', toAdd) + ]); + }; + var addUpdate = function () { + if (!updatedOn) { return; } + var d = new Date(updatedOn).toLocaleDateString(); + $translated.append(h('div', Messages._getKey('calendar_rec_updated', [d]))); + }; + var addTranslation = function () { + _addTranslation(); + addUpdate(); + }; + + addTranslation(); + + + + var showCustom = function () { + var rec = APP.recurrenceRule || {}; + + var interval = h('input', { + type: "number", + min: 1, + max: 1000, + value: rec.interval || 1 + }); + + var options = []; + ['daily', 'weekly', 'monthly', 'yearly'].forEach(function (rec) { + options.push({ + tag: 'a', + attributes: { + 'class': 'cp-calendar-recurrence-freq', + 'data-value': rec, + 'href': '#', + }, + content: Messages['calendar_rec_freq_' + rec] + }); + }); + var dropdownConfig = { + text: Messages.calendar_rec_freq_daily, + options: options, // Entries displayed in the menu + isSelect: true, + common: common, + buttonCls: 'btn btn-secondary', + caretDown: true, + }; + var $freq = UIElements.createDropdown(dropdownConfig); + $freq.setValue(rec.freq || 'daily'); + + var radioNo = UI.createRadio('cp-calendar-rec-until', 'cp-calendar-rec-until-no', + Messages.calendar_rec_until_no, !rec.until && !rec.count, {}); + var pickr; + var untilDate = [ + h('span', Messages.calendar_rec_until_date), + pickr = h('input', {readonly:"readonly"}) + ]; + var startPickr = Flatpickr(pickr, { + enableTime: false, + minDate: date, + //dateFormat: dateFormat, + onChange: function () { + //endPickr.set('minDate', startPickr.parseDate(s.value)); + } + }); + var endDate = new Date(+date); + endDate.setMonth(endDate.getMonth() + 1); + startPickr.setDate(rec.until ? new Date(rec.until) : endDate); + var radioDate = UI.createRadio('cp-calendar-rec-until', 'cp-calendar-rec-until-date', + untilDate, Boolean(rec.until), {input:{'data-value':'date'}}); + var untilCount = [ + h('span', Messages.calendar_rec_until_count), + h('input', {type: "number", value: (rec.count || 5), min: 2}), + h('span', Messages.calendar_rec_until_count2), + ]; + var radioCount = UI.createRadio('cp-calendar-rec-until', 'cp-calendar-rec-until-count', + untilCount, Boolean(rec.count), {input:{'data-value':'count'}}); + var untilEls = [radioNo, radioDate, radioCount]; + $(untilEls).find('.cp-checkmark-label input').click(function () { + $(this).closest('.cp-radio').find('input[type="radio"]').prop('checked', true); + }); + + var repeat = h('div.cp-calendar-rec-inline', [ + h('span', Messages.calendar_rec_txt), + interval, + $freq[0] + ]); + var until = h('div.cp-calendar-rec-block.radio-group', [ + h('div.cp-calendar-rec-block-title', Messages.calendar_rec_until), + untilEls + ]); + + var expand = h('div'); + var $expand = $(expand); + var EXPAND = {}; + EXPAND.daily = function () {}; + EXPAND.weekly = function () { + $expand.attr('class', 'cp-calendar-rec-inline cp-calendar-weekly-pick'); + var days = getWeekDays(); + var active = (rec.by && rec.by.day) || [Rec.DAYORDER[date.getDay()]]; + var line = [], dayCode; + for (var i = 1; i < 8; i++) { + dayCode = Rec.DAYORDER[i%7]; + line.push(h('button.btn.no-margin.cp-calendar-pick-el' + + (active.includes(dayCode) ? '.btn-primary' : '.btn-default'), { + 'data-value': dayCode + }, days[i%7])); + } + $expand.append(line); + $expand.find('button').click(function () { + var $b = $(this); + if ($b.is('.btn-primary')) { + return $b.removeClass('btn-primary').addClass('btn-default'); + } + $b.removeClass('btn-default').addClass('btn-primary'); + }); + }; + EXPAND.monthly = function () { + $expand.attr('class', 'cp-calendar-rec-block radio-group'); + // Display one or two radio options accordingly + var checked = (rec.by && rec.by.day) || []; + var pattern = getMonthlyPattern(date); + var radioNth = UI.createRadio('cp-calendar-rec-monthly', 'cp-calendar-rec-monthly-nth', + pattern.str, checked.includes(pattern.nth), + {input:{'data-value':pattern.nth }}); + $expand.append(radioNth); + + if (pattern.last) { + var radioLast = UI.createRadio('cp-calendar-rec-monthly', 'cp-calendar-rec-monthly-last', + pattern.lastStr, checked.includes(pattern.last), + {input:{ 'data-value': pattern.last }}); + $expand.append(radioLast); + } + + var active = (rec.by && rec.by.monthday) || [date.getDate()]; + var lines = [], l, n; + for (var i = 0; i < 5; i++) { + l = []; + for (var j = 1; j < 8; j++) { + n = i * 7 + j; + if (n > 31) { + l.push(h('button.btn.no-margin.cp-calendar-pick-el.lastday' + + (active.includes(-1) ? '.btn-primary' : '.btn-default'), { + 'data-value': -1 + }, Messages.calendar_month_last)); + break; + } + l.push(h('button.btn.no-margin.cp-calendar-pick-el' + + (active.includes(n) ? '.btn-primary' : '.btn-default'), { + 'data-value': n + }, n)); + } + lines[i] = h('div', l); + } + + var pickr = h('div.cp-calendar-monthly-pick', lines); + $(pickr).find('button').click(function () { + var $b = $(this); + if ($b.is('.btn-primary')) { + return $b.removeClass('btn-primary').addClass('btn-default'); + } + $b.removeClass('btn-default').addClass('btn-primary'); + }); + var radioPickContent = [ + h('span', Messages.calendar_rec_monthly_pick), + pickr + ]; + var radioPick = UI.createRadio('cp-calendar-rec-monthly', 'cp-calendar-rec-monthly-pick', + radioPickContent, !checked.length, {input:{'data-value':'pick'}}); + $expand.append(radioPick); + + $expand.find('.cp-checkmark-label button').click(function () { + $(this).closest('.cp-radio').find('input[type="radio"]').prop('checked', true); + }); + }; + EXPAND.yearly = function () { + $expand.attr('class', 'cp-calendar-rec-block radio-group'); + + var checked = (rec.by && rec.by.day) || []; + + var radioDate = UI.createRadio('cp-calendar-rec-yearly', + 'cp-calendar-rec-yearly-date', + Messages._getKey('calendar_rec_every_date', [ + date.toLocaleDateString(getDateLanguage(), { month: 'long', day: 'numeric'}) + ]), !checked.length, { 'data-value': '' }); + $expand.append(radioDate); + + var pattern = getMonthlyPattern(date, true); + var radioNth = UI.createRadio('cp-calendar-rec-yearly', 'cp-calendar-rec-yearly-nth', + pattern.str, checked.includes(pattern.nth), + {input:{ 'data-value': pattern.nth }}); + $expand.append(radioNth); + + if (pattern.last) { + var radioLast = UI.createRadio('cp-calendar-rec-yearly', 'cp-calendar-rec-yearly-last', + pattern.lastStr, checked.includes(pattern.last), + {input:{ 'data-value': pattern.last }}); + $expand.append(radioLast); + } + }; + EXPAND[rec.freq || "daily"](); + + var currentFreq = rec.freq || 'daily'; + $freq.onChange.reg(function (prettyVal, val) { + if (val === currentFreq || !val) { return; } + currentFreq = val; + rec = {}; + $expand.empty(); + EXPAND[val](); + }); + + + var content = [repeat, expand, until]; + + var $modal; + var modal = UI.dialog.customModal(content, { + buttons: [{ + className: 'cancel', + name: Messages.cancel, + onClick: function () { + if (!APP.recurrenceRule) { $block.setValue(''); } + }, + keys: [27] + }, { + className: 'primary', + name: Messages.settings_save, + onClick: function () { + var freq = $freq.getValue(); + + var rec = APP.recurrenceRule = { + freq: freq, + interval: Number($(interval).val()) || 1, + by: {} + }; + if (rec.interval === 1) { delete rec.interval; } + + var until = $modal.find('input[name="cp-calendar-rec-until"]:checked').data('value'); + if (until === "count") { + rec.count = $(radioCount).find('input[type="number"]').val(); + } else if (until === "date") { + var _date = Flatpickr.parseDate(pickr.value); + _date.setDate(_date.getDate()+1); + rec.until = +_date - 1; + } + + if (freq === "weekly") { + rec.by.day = []; + $expand.find('button.cp-calendar-pick-el.btn-primary') + .each(function (i, el) { + rec.by.day.push($(el).data('value')); + }); + if (!rec.by.day.length) { delete rec.by.day; } + } + + if (freq === "monthly") { + var _m = $expand.find('input[name="cp-calendar-rec-monthly"]:checked').data('value'); + if (_m === "pick") { + rec.by.monthday = []; + $expand.find('div.cp-calendar-monthly-pick button.btn-primary') + .each(function (i, el) { + rec.by.monthday.push($(el).data('value')); + }); + if (!rec.by.monthday.length) { delete rec.by.monthday; } + } else { + rec.by.day = [_m]; + } + } + + if (freq === "yearly") { + var _y = $expand.find('input[name="cp-calendar-rec-yearly"]:checked').data('value'); + if (_y) { + rec.by.month = [date.getMonth()+1]; + rec.by.day = [_y]; + } + } + + if (!Object.keys(rec.by).length) { delete rec.by; } + + addTranslation(); + }, + keys: [13] + }] + }); + $modal = $(modal); + UI.openCustomModal(modal); + $modal.closest('.alertify').on('mousedown', function (e) { + e.stopPropagation(); + }); + }; + + $block.onChange.reg(function(name, val) { + if (val === "custom") { return void showCustom(); } + APP.recurrenceRule = val; + addTranslation(); + }); + + return h('div.cp-calendar-recurrence-container', [ + h('span.cp-recurrence-label', [ + h('i.fa.fa-repeat', {'aria-hidden':'true'}), + Messages.calendar_rec]), + $block[0], + translated + ]); + }; + var parseNotif = function (minutes) { var res = { unit: 'minutes', @@ -1020,7 +1828,13 @@ define([ var ev = APP.editModalData; var calId = ev.selectedCal.id; // DEFAULT HERE [10] ==> 10 minutes before the event - var oldReminders = Util.find(APP.calendars, [calId, 'content', 'content', ev.id, 'reminders']) || [10]; + var id = (ev.id && ev.id.split('|')[0]) || undefined; + var _ev = APP.calendar.getSchedule(ev.id, calId); + var oldReminders = _ev && _ev.raw && _ev.raw.reminders; + if (!oldReminders) { + oldReminders = Util.find(APP.calendars, [calId, 'content', 'content', id, 'reminders']) || [60]; + } + APP.notificationsEntries = []; var number = h('input.tui-full-calendar-content', { type: "number", @@ -1064,13 +1878,19 @@ define([ var addNotif = h('button.btn.btn-primary-outline.fa.fa-plus'); var $list = $(h('div.cp-calendar-notif-list')); var listContainer = h('div.cp-calendar-notif-list-container', [ - h('span.cp-notif-label', Messages.calendar_notifications), + h('span.cp-notif-label', [ + h('i.fa.fa-bell', {'aria-hidden':'true'}), + Messages.calendar_notifications + ]), $list[0], h('span.cp-notif-empty', Messages.calendar_noNotification) ]); + var addNotification = function (unit, value) { var unitValue = (unit === "minutes") ? 1 : (unit === "hours" ? 60 : (60*24)); - var del = h('button.btn.btn-danger-outline.small.fa.fa-times'); + var del = h('button.btn.btn-danger-outline.small.fa.fa-times', + {'title': Messages.calendar_removeNotification} + ); var minutes = value * unitValue; if ($list.find('[data-minutes="'+minutes+'"]').length) { return; } var span = h('span.cp-notif-entry', { @@ -1182,6 +2002,14 @@ define([ $el.find('.tui-full-calendar-dropdown-button').addClass('btn btn-secondary'); $el.find('.tui-full-calendar-popup-close').addClass('btn btn-cancel fa fa-times cp-calendar-close').empty(); + var $container = $el.closest('.tui-full-calendar-floating-layer'); + $container.addClass('cp-calendar-popup-flex'); + $container.css('display', 'flex').mousedown(function (e) { + if ($(e.target).is('.cp-calendar-popup-flex')) { + $el.find('.tui-full-calendar-popup-close').click(); + } + }); + var calendars = APP.calendars || {}; var show = false; $el.find('.tui-full-calendar-dropdown-menu li').each(function (i, li) { @@ -1203,31 +2031,33 @@ define([ if (!isUpdate) { $el.find('.tui-full-calendar-dropdown-menu li').first().click(); } var $button = $el.find('.tui-full-calendar-section-button-save'); + + var $startDate = $el.find('#tui-full-calendar-schedule-start-date'); + var startDate = Flatpickr.parseDate($startDate.val()); + + var divRec = getRecurrenceInput(startDate); + $button.before(divRec); + var div = getNotificationDropdown(); $button.before(div); + // Use Flatpickr with or without time depending on allday checkbox var $cbox = $el.find('#tui-full-calendar-schedule-allday'); - var $start = $el.find('.tui-full-calendar-section-start-date'); - var $dash = $el.find('.tui-full-calendar-section-date-dash'); - var $end = $el.find('.tui-full-calendar-section-end-date'); var allDay = $cbox.is(':checked'); - if (allDay) { - $start.hide(); - $dash.hide(); - $end.hide(); - } + var allDayFormat = 'Y-m-d'; + var timeFormat = ''; + var setFormat = function (allDay) { + var s = window.CP_startPickr; + var e = window.CP_endPickr; + if (!timeFormat) { timeFormat = s.config.dateFormat; } + s.set('dateFormat', allDay ? allDayFormat : timeFormat); + e.set('dateFormat', allDay ? allDayFormat : timeFormat); + }; + setFormat(allDay); $el.find('.tui-full-calendar-section-allday').click(function () { setTimeout(function () { var allDay = $cbox.is(':checked'); - if (allDay) { - $start.hide(); - $dash.hide(); - $end.hide(); - return; - } - $start.show(); - $dash.show(); - $end.show(); + setFormat(allDay); }); }); }; @@ -1235,9 +2065,65 @@ define([ var $el = $(el); $el.find('.tui-full-calendar-popup-edit').addClass('btn btn-primary'); $el.find('.tui-full-calendar-popup-edit .tui-full-calendar-icon').addClass('fa fa-pencil').removeClass('tui-full-calendar-icon'); - $el.find('.tui-full-calendar-popup-delete').addClass('btn btn-danger'); - $el.find('.tui-full-calendar-popup-delete .tui-full-calendar-icon').addClass('fa fa-trash').removeClass('tui-full-calendar-icon'); $el.find('.tui-full-calendar-content').removeClass('tui-full-calendar-content'); + + var delButton = h('button.btn.btn-danger', [ + h('i.fa.fa-trash'), + h('span', Messages.kanban_delete) + ]); + var $del = $el.find('.tui-full-calendar-popup-delete').hide(); + $del.after(delButton); + UI.confirmButton(delButton, { + classes: 'danger' + }, function () { + $del.click(); + }); + var $section = $el.find('.tui-full-calendar-section-button'); + var ev = APP.editModalData; + var data = ev.schedule || {}; + var id = data.id; + if (!id) { return; } + if (id.indexOf('|') === -1) { return; } // Original event ID doesn't contain | + + if (APP.nextLocationUid) { + var uid = APP.nextLocationUid; + delete APP.nextLocationUid; + var $a = $el.find('#'+uid); + $a.click(function (e) { + e.preventDefault(); + e.stopPropagation(); + common.openUnsafeURL($a.attr('href')); + }); + } + // This is a recurring event, add button to stop recurrence now + var $b = $(h('button.btn.btn-default', [ + h('i.fa.fa-times'), + h('span', Messages.calendar_rec_stop) + ])).insertBefore($section); + UI.confirmButton($b[0], { classes: 'btn-default' }, function () { + var originalId = id.split('|')[0]; + var originalEvent = Util.find(APP.calendars, + [ev.schedule.calendarId, 'content', 'content', originalId]); + var rec = originalEvent.recurrenceRule; + if (!rec) { return; } + rec.until = (ev.schedule.raw && ev.schedule.raw.start) - 1; + data.id = originalId; + updateEvent({ + ev: data, + changes: { + recurrenceRule: rec + }, + type: { + which: 'all' + } + }, function (err) { + if (err) { + console.error(err); + return void UI.warn(err); + } + $b.closest('.tui-full-calendar-floating-layer').hide(); + }); + }); }; var onPopupRemoved = function () { var start, end; @@ -1313,6 +2199,8 @@ define([ }, function (obj) { if (obj && obj.error) { console.error(obj.error); } }); + } else if (privateData.calendarOpts) { + APP.initTime = privateData.calendarOpts.time; } store.get('calendarView', makeCalendar); UI.removeLoadingScreen(); diff --git a/www/calendar/main.js b/www/calendar/main.js index 11b1ba4bf..6f8e362f7 100644 --- a/www/calendar/main.js +++ b/www/calendar/main.js @@ -6,13 +6,21 @@ define([ '/common/sframe-common-outer.js', ], function (nThen, ApiConfig, DomReady, SFCommonO) { - // Loaded in load #2 + var hash, href; nThen(function (waitFor) { DomReady.onReady(waitFor()); }).nThen(function (waitFor) { - SFCommonO.initIframe(waitFor); + var obj = SFCommonO.initIframe(waitFor, true); + href = obj.href; + hash = obj.hash; }).nThen(function (/*waitFor*/) { - var addData = function (meta) { + var addData = function (meta, Cryptpad, user, Utils) { + if (hash) { + var parsed = Utils.Hash.parsePadUrl(href); + if (parsed.hashData && parsed.hashData.newPadOpts) { + meta.calendarOpts = Utils.Hash.decodeDataOptions(parsed.hashData.newPadOpts); + } + } meta.calendarHash = Boolean(window.location.hash); }; SFCommonO.start({ diff --git a/www/calendar/recurrence.js b/www/calendar/recurrence.js new file mode 100644 index 000000000..bd3fa0519 --- /dev/null +++ b/www/calendar/recurrence.js @@ -0,0 +1,869 @@ +define([ + '/common/common-util.js', +], function (Util) { + var Rec = {}; + + var debug = function () {}; + + // Get week number with any "WKST" (firts day of the week) + // Week 1 is the first week of the year containing at least 4 days in this year + // It depends on which day is considered the first day of the week (default Monday) + // In our case, wkst is a number matching the JS rule: 0 == Sunday + var getWeekNo = Rec.getWeekNo = function (date, wkst) { + if (typeof(wkst) !== "number") { wkst = 1; } // Default monday + + var newYear = new Date(date.getFullYear(),0,1); + var day = newYear.getDay() - wkst; //the day of week the year begins on + day = (day >= 0 ? day : day + 7); + var daynum = Math.floor((date.getTime() - newYear.getTime())/86400000) + 1; + var weeknum; + // Week 1 / week 53 + if (day < 4) { + weeknum = Math.floor((daynum+day-1)/7) + 1; + if (weeknum > 52) { + var nYear = new Date(date.getFullYear() + 1,0,1); + var nday = nYear.getDay() - wkst; + nday = nday >= 0 ? nday : nday + 7; + weeknum = nday < 4 ? 1 : 53; + } + } + else { + weeknum = Math.floor((daynum+day-1)/7); + } + return weeknum; + }; + + var getYearDay = function (date) { + var start = new Date(date.getFullYear(), 0, 0); + var diff = (date - start) + + ((start.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000); + var oneDay = 1000 * 60 * 60 * 24; + return Math.floor(diff / oneDay); + }; + var setYearDay = function (date, day) { + if (typeof(day) !== "number" || Math.abs(day) < 1 || Math.abs(day) > 366) { return; } + if (day < 0) { + var max = getYearDay(new Date(date.getFullYear(), 11, 31)); + day = max + day + 1; + } + date.setMonth(0); + date.setDate(day); + return true; + }; + + var getEndData = function (s, e) { + if (s > e) { return void console.error("Wrong data"); } + var days; + if (e.getFullYear() === s.getFullYear()) { + days = getYearDay(e) - getYearDay(s); + } else { // eYear < sYear + var tmp = new Date(s.getFullYear(), 11, 31); + var d1 = getYearDay(tmp) - getYearDay(s); // Number of days before December 31st + var de = getYearDay(e); + days = d1 + de; + while ((tmp.getFullYear()+1) < e.getFullYear()) { + tmp.setFullYear(tmp.getFullYear()+1); + days += getYearDay(tmp); + } + } + return { + h: e.getHours(), + m: e.getMinutes(), + days: days + }; + }; + var setEndData = function (s, e, data) { + e.setTime(+s); + if (!data) { return; } + e.setHours(data.h); + e.setMinutes(data.m); + e.setSeconds(0); + e.setDate(s.getDate() + data.days); + }; + + var DAYORDER = Rec.DAYORDER = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + var getDayData = function (str) { + var pos = Number(str.slice(0,-2)); + var day = DAYORDER.indexOf(str.slice(-2)); + return pos ? [pos, day] : day; + }; + + var goToFirstWeekDay = function (date, wkst) { + var d = date.getDay(); + wkst = typeof(wkst) === "number" ? wkst : 1; + if (d >= wkst) { + date.setDate(date.getDate() - (d-wkst)); + } else { + date.setDate(date.getDate() - (7+d-wkst)); + } + }; + + var getDateStr = function (date) { + return date.getFullYear() + '-' + (date.getMonth()+1) + '-' + date.getDate(); + }; + var FREQ = {}; + FREQ['daily'] = function (s, i) { + s.setDate(s.getDate()+i); + }; + FREQ['weekly'] = function (s,i) { + s.setDate(s.getDate()+(i*7)); + }; + FREQ['monthly'] = function (s,i) { + s.setMonth(s.getMonth()+i); + }; + FREQ['yearly'] = function (s,i) { + s.setFullYear(s.getFullYear()+i); + }; + + // EXPAND is used to create iterations added from a BYxxx rule + // dateA is the start date and b is the number or id of the BYxxx rule item + var EXPAND = {}; + EXPAND['month'] = function (dateS, origin, b) { + var oS = new Date(origin.start); + var a = dateS.getMonth() + 1; + var toAdd = (b-a+12)%12; + var m = dateS.getMonth() + toAdd; + dateS.setMonth(m); + dateS.setDate(oS.getDate()); + if (dateS.getMonth() !== m) { return; } // Day 31 may move us to the next month + return true; + }; + + + EXPAND['weekno'] = function (dateS, origin, week, rule) { + var wkst = rule && rule.wkst; + if (typeof(wkst) !== "number") { wkst = 1; } // Default monday + var oS = new Date(origin.start); + + var lastD = new Date(dateS.getFullYear(), 11, 31); // December 31st + var lastW = getWeekNo(lastD, wkst); // Last week of the year is either 52 or 53 + + var doubleOne = lastW === 1; + if (lastW === 1) { lastW = 52; } + + var a = getWeekNo(dateS, wkst); + if (!week || week > lastW) { return false; } // Week 53 may not exist this year + + if (week < 0) { week = lastW + week + 1; } // Turn negative week number into positive + + var toAdd = week - a; + var weekS = new Date(+dateS); + // Go to the selected week + weekS.setDate(weekS.getDate() + (toAdd * 7)); + goToFirstWeekDay(weekS, wkst); + + // Then make sure we are in the correct start day + var all = 'aaaaaaa'.split('').map(function (o, i) { + var date = new Date(+weekS); + date.setDate(date.getDate() + i); + if (date.getFullYear() !== dateS.getFullYear()) { return; } + return date.toLocaleDateString() !== oS.toLocaleDateString() && date; + }).filter(Boolean); + + // If we're looking for week 1 and the last week is a week 1, add the days + if (week === 1 && doubleOne) { + goToFirstWeekDay(lastD, wkst); + 'aaaaaaa'.split('').some(function (o, i) { + var date = new Date(+lastD); + date.setDate(date.getDate() + i); + if (date.toLocaleDateString() === oS.toLocaleDateString()) { return; } + if (date.getFullYear() > dateS.getFullYear()) { return true; } + all.push(date); + }); + } + + return all.length ? all : undefined; + }; + EXPAND['yearday'] = function (dateS, origin, b) { + var y = dateS.getFullYear(); + var state = setYearDay(dateS, b); + if (!state) { return; } // Invalid day "b" + if (dateS.getFullYear() !== y) { return; } // Day 366 make move us to the next year + return true; + }; + EXPAND['monthday'] = function (dateS, origin, b, rule) { + if (typeof(b) !== "number" || Math.abs(b) < 1 || Math.abs(b) > 31) { return false; } + + var setMonthDay = function (date, day) { + var m = date.getMonth(); + if (day < 0) { + var tmp = new Date(date.getFullYear(), date.getMonth()+1, 0); // Last day + day = tmp.getDate() + day + 1; + } + date.setDate(day); + return date.getMonth() === m; // Don't push if day 31 moved us to the next month + + }; + + // Monthly events + if (rule.freq === 'monthly') { + return setMonthDay(dateS, b); + } + + var all = 'aaaaaaaaaaaa'.split('').map(function (o, i) { + var date = new Date(dateS.getFullYear(), i, 1); + var ok = setMonthDay(date, b); + return ok ? date : undefined; + }).filter(Boolean); + return all.length ? all : undefined; + }; + EXPAND['day'] = function (dateS, origin, b, rule) { + // Here "b" can be a single day ("TU") or a position and a day ("1MO") + var day = getDayData(b); + var pos; + if (Array.isArray(day)) { + pos = day[0]; + day = day[1]; + } + + var all = []; + if (![0,1,2,3,4,5,6].includes(day)) { return false; } + + var filterPos = function (m) { + if (!pos) { return; } + + var _all = []; + 'aaaaaaaaaaaa'.split('').some(function (a, i) { + if (typeof(m) !== "undefined" && i !== m) { return; } + + var _pos; + var tmp = all.filter(function (d) { + return d.getMonth() === i; + }); + if (pos < 0) { + _pos = tmp.length + pos; + } else { + _pos = pos - 1; // An array starts at 0 but the recurrence rule starts at 1 + } + _all.push(tmp[_pos]); + + return typeof(m) !== "undefined" && i === m; + }); + all = _all.filter(Boolean); // The "5th" {day} won't always exist + }; + + var tmp; + if (rule.freq === 'yearly') { + tmp = new Date(+dateS); + var y = dateS.getFullYear(); + while (tmp.getDay() !== day) { tmp.setDate(tmp.getDate()+1); } + while (tmp.getFullYear() === y) { + all.push(new Date(+tmp)); + tmp.setDate(tmp.getDate()+7); + } + filterPos(); + return all; + } + + if (rule.freq === 'monthly') { + tmp = new Date(+dateS); + var m = dateS.getMonth(); + while (tmp.getDay() !== day) { tmp.setDate(tmp.getDate()+1); } + while (tmp.getMonth() === m) { + all.push(new Date(+tmp)); + tmp.setDate(tmp.getDate()+7); + } + filterPos(m); + return all; + } + + if (rule.freq === 'weekly') { + while (dateS.getDay() !== day) { dateS.setDate(dateS.getDate()+1); } + } + return true; + }; + + var LIMIT = {}; + LIMIT['month'] = function (events, rule) { + return events.filter(function (s) { + return rule.includes(s.getMonth()+1); + }); + }; + LIMIT['weekno'] = function (events, weeks, rules) { + return events.filter(function (s) { + var wkst = rules && rules.wkst; + if (typeof(wkst) !== "number") { wkst = 1; } // Default monday + + var lastD = new Date(s.getFullYear(), 11, 31); // December 31st + var lastW = getWeekNo(lastD, wkst); // Last week of the year is either 52 or 53 + if (lastW === 1) { lastW = 52; } + + var w = getWeekNo(s, wkst); + + return weeks.some(function (week) { + if (week > 0) { return week === w; } + return w === (lastW + week + 1); + }); + }); + }; + LIMIT['yearday'] = function (events, days) { + return events.filter(function (s) { + var d = getYearDay(s); + var max = getYearDay(new Date(s.getFullYear(), 11, 31)); + + return days.some(function (day) { + if (day > 0) { return day === d; } + return d === (max + day + 1); + }); + }); + }; + LIMIT['monthday'] = function (events, rule) { + return events.filter(function (s) { + var r = Util.clone(rule); + // Transform the negative monthdays into positive for this specific month + r = r.map(function (b) { + if (b < 0) { + var tmp = new Date(s.getFullYear(), s.getMonth()+1, 0); // Last day + b = tmp.getDate() + b + 1; + } + return b; + }); + return r.includes(s.getDate()); + }); + }; + LIMIT['day'] = function (events, days, rules) { + return events.filter(function (s) { + var dayStr = s.toLocaleDateString(); + + // Check how to handle position in BYDAY rules (last day of the month or the year?) + var type = 'yearly'; + if (rules.freq === 'monthly' || + (rules.freq === 'yearly' && rules.by && rules.by.month)) { + type = 'monthly'; + } + + // Check if this event matches one of the allowed days + return days.some(function (r) { + // rule elements are strings with pos and day + var day = getDayData(r); + var pos; + if (Array.isArray(day)) { + pos = day[0]; + day = day[1]; + } + if (!pos) { + return s.getDay() === day; + } + + // If we have a position, we can use EXPAND.day to get the nth {day} of the + // year/month and compare if it matches with + var d = new Date(s.getFullYear(), s.getMonth(), 1); + if (type === 'yearly') { d.setMonth(0); } + var res = EXPAND["day"](d, {}, r, {freq: type}); + return res.some(function (date) { + return date.toLocaleDateString() === dayStr; + }); + }); + }); + }; + LIMIT['setpos'] = function (events, rule) { + var init = events.slice(); + var rules = Util.deduplicateString(rule.slice().map(function (n) { + if (n > 0) { return (n-1); } + if (n === 0) { return; } + return init.length + n; + })); + return events.filter(function (ev) { + var idx = init.indexOf(ev); + return rules.includes(idx); + }); + }; + + var BYORDER = ['month','weekno','yearday','monthday','day']; + var BYDAYORDER = ['month','monthday','day']; + + Rec.getMonthId = function (d) { + return d.getFullYear() + '-' + d.getMonth(); + }; + var cache = window.CP_calendar_cache = {}; + var recurringAcross = {}; + Rec.resetCache = function () { + cache = window.CP_calendar_cache = {}; + recurringAcross = {}; + }; + + var iterate = function (rule, _origin, s) { + // "origin" is the original event to detect the start of BYxxx + var origin = Util.clone(_origin); + var oS = new Date(origin.start); + + var id = origin.id.split('|')[0]; // Use same cache when updating recurrence rule + + // "uid" is used for the cache + var uid = s.toLocaleDateString(); + cache[id] = cache[id] || {}; + + var inter = rule.interval || 1; + var freq = rule.freq; + + var all = []; + var limit = function (byrule, n) { + all = LIMIT[byrule](all, n, rule); + }; + var expand = function (byrule) { + return function (n) { + // Set the start date at the beginning of the current FREQ + var _s = new Date(+s); + if (rule.freq === 'yearly') { + // January 1st + _s.setMonth(0); + _s.setDate(1); + } else if (rule.freq === 'monthly') { + _s.setDate(1); + } else if (rule.freq === 'weekly') { + goToFirstWeekDay(_s, rule.wkst); + } else if (rule.freq === 'daily') { + // We don't have < byday rules so we can't expand daily rules + } + + var add = EXPAND[byrule](_s, origin, n, rule); + + if (!add) { return; } + + if (Array.isArray(add)) { + add = add.filter(function (dateS) { + return dateS.toLocaleDateString() !== oS.toLocaleDateString(); + }); + Array.prototype.push.apply(all, add); + } else { + if (_s.toLocaleDateString() === oS.toLocaleDateString()) { return; } + all.push(_s); + } + }; + }; + + // Manage interval for the next iteration + var it = Util.once(function () { + FREQ[freq](s, inter); + }); + var addDefault = function () { + if (freq === "monthly") { + s.setDate(15); + } else if (freq === "yearly" && oS.getMonth() === 1 && oS.getDate() === 29) { + s.setDate(28); + } + + it(); + + var _s = new Date(+s); + if (freq === "monthly" || freq === "yearly") { + _s.setDate(oS.getDate()); + if (_s.getDate() !== oS.getDate()) { return; } // If 31st or Feb 29th doesn't exist + if (freq === "yearly" && _s.getMonth() !== oS.getMonth()) { return; } + + // FIXME if there is a recUpdate that moves the 31st to the 30th, the event + // will still only be displayed on months with 31 days + } + all.push(_s); + }; + + if (Array.isArray(cache[id][uid])) { + debug('Get cache', id, uid); + if (freq === "monthly") { + s.setDate(15); + } else if (freq === "yearly" && oS.getMonth() === 1 && oS.getDate() === 29) { + s.setDate(28); + } + it(); + return cache[id][uid]; + } + + if (rule.by && freq === 'yearly') { + var order = BYORDER.slice(); + var monthLimit = false; + if (rule.by.weekno || rule.by.yearday || rule.by.monthday || rule.by.day) { + order.shift(); + monthLimit = true; + } + var first = true; + order.forEach(function (_order) { + var r = rule.by[_order]; + if (!r) { return; } + if (first) { + r.forEach(expand(_order)); + first = false; + } else if (_order === "day") { + if (rule.by.yearday || rule.by.monthday || rule.by.weekno) { + limit('day', rule.by.day); + } else { + rule.by.day.forEach(expand('day')); + } + } else { + limit(_order, r); + } + }); + if (rule.by.month && monthLimit) { + limit('month', rule.by.month); + } + } + if (rule.by && freq === 'monthly') { + // We're going to compute all the entries for the coming month + if (!rule.by.monthday && !rule.by.day) { + addDefault(); + } else if (rule.by.monthday) { + rule.by.monthday.forEach(expand('monthday')); + } else if (rule.by.day) { + rule.by.day.forEach(expand('day')); + } + if (rule.by.month) { + limit('month', rule.by.month); + } + if (rule.by.day && rule.by.monthday) { + limit('day', rule.by.day); + } + } + if (rule.by && freq === 'weekly') { + // We're going to compute all the entries for the coming week + if (!rule.by.day) { + addDefault(); + } else { + rule.by.day.forEach(expand('day')); + } + if (rule.by.month) { + limit('month', rule.by.month); + } + } + if (rule.by && freq === 'daily') { + addDefault(); + BYDAYORDER.forEach(function (_order) { + var r = rule.by[_order]; + if (!r) { return; } + limit(_order, r); + }); + } + + all.sort(function (a, b) { + return a-b; + }); + + if (rule.by && rule.by.setpos) { + limit('setpos', rule.by.setpos); + } + + if (!rule.by || !Object.keys(rule.by).length) { + addDefault(); + } else { + it(); + } + + + var done = []; + all = all.filter(function (newS) { + var start = new Date(+newS).toLocaleDateString(); + if (done.includes(start)) { return false; } + done.push(start); + return true; + }); + + debug('Set cache', id, uid); + cache[id][uid] = all; + + return all; + }; + + var getNextRules = function (obj) { + if (!obj.recUpdate) { return []; } + var _allRules = {}; + var _obj = obj.recUpdate.from; + Object.keys(_obj || {}).forEach(function (d) { + var u = _obj[d]; + if (u.recurrenceRule) { _allRules[d] = u.recurrenceRule; } + }); + return Object.keys(_allRules).sort(function (a, b) { return Number(a)-Number(b); }) + .map(function (k) { + var r = Util.clone(_allRules[k]); + if (!FREQ[r.freq]) { return; } + if (r.interval && r.interval < 1) { return; } + r._start = Number(k); + return r; + }).filter(Boolean); + }; + Rec.getRecurring = function (months, events) { + if (window.CP_DEV_MODE) { debug = console.warn; } + + var toAdd = []; + months.forEach(function (monthId) { + // from 1st day of the month at 00:00 to last day at 23:59:59:999 + var ms = monthId.split('-'); + var _startMonth = new Date(ms[0], ms[1]); + var _endMonth = new Date(+_startMonth); + _endMonth.setMonth(_endMonth.getMonth() + 1); + _endMonth.setMilliseconds(-1); + + debug('Compute month', _startMonth.toLocaleDateString()); + + var rec = events || []; + rec.forEach(function (obj) { + var _start = new Date(obj.start); + var _end = new Date(obj.end); + var _origin = obj; + var rule = obj.recurrenceRule; + if (!rule) { return; } + + var nextRules = getNextRules(obj); + var nextRule = nextRules.shift(); + + if (_start >= _endMonth) { return; } + + // Check the "until" date of the latest rule we can use and stop now + // if the recurrence ends before the current month + var until = rule.until; + var _nextRules = nextRules.slice(); + var _nextRule = nextRule; + while (_nextRule && _nextRule._start && _nextRule._start < _startMonth) { + until = nextRule.until; + _nextRule = _nextRules.shift(); + } + if (until < _startMonth) { return; } + + var endData = getEndData(_start, _end); + + if (rule.interval && rule.interval < 1) { return; } + if (!FREQ[rule.freq]) { return; } + + /* + // Rule examples + rule.by = { + //month: [1, 4, 5, 8, 12], + //weekno: [1, 2, 4, 5, 32, 34, 35, 50], + //yearday: [1, 2, 29, 30, -2, -1, 250], + //monthday: [1, 2, 3, -3, -2, -1], + //day: ["MO", "WE", "FR"], + //setpos: [1, 2, -1, -2] + }; + rule.wkst = 0; + rule.interval = 2; + rule.freq = 'yearly'; + rule.count = 10; + */ + debug('Iterate over', obj.title, obj); + debug('Use rule', rule); + + var count = rule.count; + var c = 1; + + var next = function (start) { + var evS = new Date(+start); + + if (count && c >= count) { return; } + + debug('Start iteration', evS.toLocaleDateString()); + + var _toAdd = iterate(rule, obj, evS); + + debug('Iteration results', JSON.stringify(_toAdd.map(function (o) { return new Date(o).toLocaleDateString();}))); + + // Make sure to continue if the current year doesn't provide any result + if (!_toAdd.length) { + if (evS.getFullYear() < _startMonth.getFullYear() || + evS < _endMonth) { + return void next(evS); + } + return; + } + + + var stop = false; + var newrule = false; + _toAdd.some(function (_newS) { + // Make event with correct start and end time + var _ev = Util.clone(obj); + _ev.id = _origin.id + '|' + (+_newS); + var _evS = new Date(+_newS); + var _evE = new Date(+_newS); + setEndData(_evS, _evE, endData); + _ev.start = +_evS; + _ev.end = +_evE; + _ev._count = c; + if (_ev.isAllDay && _ev.startDay) { _ev.startDay = getDateStr(_evS); } + if (_ev.isAllDay && _ev.endDay) { _ev.endDay = getDateStr(_evE); } + + if (nextRule && _ev.start === nextRule._start) { + newrule = true; + } + + var useNewRule = function () { + if (!newrule) { return; } + debug('Use new rule', nextRule); + _ev._count = c; + count = nextRule.count; + c = 1; + evS = +_evS; + obj = _ev; + rule = nextRule; + nextRule = nextRules.shift(); + }; + + + if (c >= count) { // Limit reached + debug(_evS.toLocaleDateString(), 'count'); + stop = true; + return true; + } + if (_evS >= _endMonth) { // Won't affect us anymore + debug(_evS.toLocaleDateString(), 'endMonth'); + stop = true; + return true; + } + if (rule.until && _evS > rule.until) { + debug(_evS.toLocaleDateString(), 'until'); + stop = true; + return true; + } + if (_evS < _start) { // "Expand" rules may create events before the _start + debug(_evS.toLocaleDateString(), 'start'); + return; + } + c++; + if (_evE < _startMonth) { // Ended before the current month + // Nothing to display but continue the recurrence + debug(_evS.toLocaleDateString(), 'startMonth'); + if (newrule) { useNewRule(); } + return; + } + // If a recurring event start and end in different months, make sure + // it is only added once + if ((_evS < _endMonth && _evE >= _endMonth) || + (_evS < _startMonth && _evE >= _startMonth)) { + if (recurringAcross[_ev.id] && recurringAcross[_ev.id].includes(_ev.start)) { + return; + } else { + recurringAcross[_ev.id] = recurringAcross[_ev.id] || []; + recurringAcross[_ev.id].push(_ev.start); + } + + } + + // Add this event + toAdd.push(_ev); + if (newrule) { + useNewRule(); + return true; + } + }); + if (!stop) { next(evS); } + }; + next(_start); + debug('Added this month (all events)', toAdd.map(function (ev) { + return new Date(ev.start).toLocaleDateString(); + })); + }); + }); + return toAdd; + }; + Rec.getAllOccurrences = function (ev) { + if (!ev.recurrenceRule) { return [ev.start]; } + var r = ev.recurrenceRule; + // In case of infinite recursion, we can't get all + if (!r.until && !r.count) { return false; } + var all = [ev.start]; + var d = new Date(ev.start); + d.setDate(15); // Make sure we won't skip a month if the event starts on day > 28 + var toAdd = []; + + var i = 0; + var check = function () { + return r.count ? (all.length < r.count) : (+d <= r.until); + }; + while ((toAdd = Rec.getRecurring([Rec.getMonthId(d)], [ev])) && check() && i < (r.count*12)) { + Array.prototype.push.apply(all, toAdd.map(function (_ev) { return _ev.start; })); + d.setMonth(d.getMonth() + 1); + i++; + } + + return all; + }; + + Rec.diffDate = function (oldTime, newTime) { + var n = new Date(newTime); + var o = new Date(oldTime); + + // Diff Days + var d = 0; + var mult = n < o ? -1 : 1; + while (n.toLocaleDateString() !== o.toLocaleDateString() || mult >= 10000) { + n.setDate(n.getDate() - mult); + d++; + } + d = mult * d; + + // Diff hours + n = new Date(newTime); + var h = n.getHours() - o.getHours(); + + // Diff minutes + var m = n.getMinutes() - o.getMinutes(); + + return { + d: d, + h: h, + m: m + }; + }; + + var sortUpdate = function (obj) { + return Object.keys(obj).sort(function (d1, d2) { + return Number(d1) - Number(d2); + }); + }; + Rec.applyUpdates = function (events) { + events.forEach(function (ev) { + ev.raw = { + start: ev.start, + end: ev.end, + }; + + if (!ev.recUpdate) { return; } + + var from = ev.recUpdate.from || {}; + var one = ev.recUpdate.one || {}; + var s = ev.start; + + // Add "until" date to our recurrenceRule if it has been modified in future occurences + var nextRules = getNextRules(ev).filter(function (r) { + return r._start > s; + }); + var nextRule = nextRules.shift(); + + var applyDiff = function (obj, k) { + var diff = obj[k]; // Diff is always compared to origin start/end + var d = new Date(ev.raw[k]); + d.setDate(d.getDate() + diff.d); + d.setHours(d.getHours() + diff.h); + d.setMinutes(d.getMinutes() + diff.m); + ev[k] = +d; + }; + + sortUpdate(from).forEach(function (d) { + if (s < Number(d)) { return; } + Object.keys(from[d]).forEach(function (k) { + if (k === 'start' || k === 'end') { return void applyDiff(from[d], k); } + if (k === "recurrenceRule" && !from[d][k]) { return; } + ev[k] = from[d][k]; + }); + }); + Object.keys(one[s] || {}).forEach(function (k) { + if (k === 'start' || k === 'end') { return void applyDiff(one[s], k); } + if (k === "recurrenceRule" && !one[s][k]) { return; } + ev[k] = one[s][k]; + }); + if (ev.deleted) { + Object.keys(ev).forEach(function (k) { + delete ev[k]; + }); + } + + if (nextRule && ev.recurrenceRule) { + ev.recurrenceRule._next = nextRule._start - 1; + } + + if (ev.reminders) { + ev.raw.reminders = ev.reminders; + } + }); + return events; + }; + + + return Rec; +}); diff --git a/www/checkup/main.js b/www/checkup/main.js index b69c5441e..05049562b 100644 --- a/www/checkup/main.js +++ b/www/checkup/main.js @@ -1566,7 +1566,7 @@ define([ console.error(err); } - return h(`div.errorcp-test-status.${obj.type}`, [ + return h(`div.error.cp-test-status.${obj.type}`, [ h('h5', obj.message), h('div.table-container', h('table', [ diff --git a/www/code/inner.js b/www/code/inner.js index c1dac2df0..562e08556 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -68,6 +68,7 @@ define([ 'markdown', 'gfm', 'html', + 'asciidoc', 'htmlembedded', 'htmlmixed', 'index.html', @@ -143,6 +144,31 @@ define([ previews['htmlmixed'] = function (val, $div, common) { DiffMd.apply(val, $div, common); }; + previews['asciidoc'] = function (val, $div, common) { + require([ + 'asciidoctor', + '/lib/highlight/highlight.pack.js', + 'css!/lib/highlight/styles/' + (window.CryptPad_theme === 'dark' ? 'dark.css' : 'github.css') + ], function (asciidoctor) { + var reg = asciidoctor.Extensions.create(); + var Highlight = window.hljs; + + reg.inlineMacro('media-tag', function () { + var t = this; + t.process(function (parent, target) { + var d = target.split('|'); + return t.createInline(parent, 'quoted', ``).convert(); + }); + }); + + var html = asciidoctor.convert(val, { attributes: 'showtitle', extension_registry: reg }); + + DiffMd.apply(html, $div, common); + $div.find('pre code').each(function (i, el) { + Highlight.highlightBlock(el); + }); + }); + }; var mkPreviewPane = function (editor, CodeMirror, framework, isPresentMode) { var $previewContainer = $('#cp-app-code-preview'); @@ -370,9 +396,17 @@ define([ evModeChange.reg(function (mode) { if (MEDIA_TAG_MODES.indexOf(mode) !== -1) { // Embedding is enabled - framework.setMediaTagEmbedder(function (mt) { + framework.setMediaTagEmbedder(function (mt, d) { editor.focus(); - editor.replaceSelection($(mt)[0].outerHTML); + var txt = $(mt)[0].outerHTML; + if (editor.getMode().name === "asciidoc") { + if (d.static) { + txt = d.href + `[${d.name}]`; + } else { + txt = `media-tag:${d.src}|${d.key}[]`; + } + } + editor.replaceSelection(txt); }); } else { // Embedding is disabled diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 7fce36d96..1255f16ae 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1417,7 +1417,7 @@ define([ return /HTML/.test(Object.prototype.toString.call(o)) && typeof(o.tagName) === 'string'; }; - var allowedTags = ['a', 'p', 'hr', 'div']; + var allowedTags = ['a', 'li', 'p', 'hr', 'div']; var isValidOption = function (o) { if (typeof o !== "object") { return false; } if (isElement(o)) { return true; } @@ -1541,6 +1541,7 @@ define([ $innerblock.find('.cp-dropdown-element-active').removeClass('cp-dropdown-element-active'); if (config.isSelect && value) { // We use JSON.stringify here to escape quotes + if (typeof(value) === "object") { value = JSON.stringify(value); } var $val = $innerblock.find('[data-value='+JSON.stringify(value)+']'); setActive($val); try { diff --git a/www/common/common-util.js b/www/common/common-util.js index efcb46227..05da8bf93 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -36,6 +36,7 @@ }; Util.clone = function (o) { + if (o === undefined || o === null) { return o; } return JSON.parse(JSON.stringify(o)); }; diff --git a/www/common/cryptpad-common.js b/www/common/cryptpad-common.js index 05d3ad46c..049717a78 100644 --- a/www/common/cryptpad-common.js +++ b/www/common/cryptpad-common.js @@ -1223,8 +1223,8 @@ define([ pad.onMetadataEvent = Util.mkEvent(); pad.onChannelDeleted = Util.mkEvent(); - pad.requestAccess = function (data, cb) { - postMessage("REQUEST_PAD_ACCESS", data, cb); + pad.contactOwner = function (data, cb) { + postMessage("CONTACT_PAD_OWNER", data, cb); }; pad.giveAccess = function (data, cb) { postMessage("GIVE_PAD_ACCESS", data, cb); diff --git a/www/common/drive-ui.js b/www/common/drive-ui.js index 4a3f4631c..475b192e1 100644 --- a/www/common/drive-ui.js +++ b/www/common/drive-ui.js @@ -78,6 +78,7 @@ define([ var TAGS_NAME = Messages.fm_tagsName; var SHARED_FOLDER = 'sf'; var SHARED_FOLDER_NAME = Messages.fm_sharedFolderName; + var FILTER = "filter"; // Icons var faFolder = 'cptools-folder'; @@ -1149,8 +1150,12 @@ define([ common.getMediaTagPreview(mts, idx); }; + var FILTER_BY = "filterBy"; + var refresh = APP.refresh = function () { - APP.displayDirectory(currentPath); + var type = APP.store[FILTER_BY]; + var path = type ? [FILTER, type, currentPath] : currentPath; + APP.displayDirectory(path); }; // `app`: true (force open wiht the app), false (force open in preview), @@ -2946,66 +2951,140 @@ define([ openIn(type, path, APP.team); }); }; + var getNewPadOptions = function (isInRoot) { + var options = []; + if (isInRoot) { + options.push({ + class: 'cp-app-drive-new-folder', + icon: $folderIcon.clone()[0], + name: Messages.fm_folder, + }); + if (!APP.disableSF && !manager.isInSharedFolder(currentPath)) { + options.push({ + class: 'cp-app-drive-new-shared-folder', + icon: $sharedFolderIcon.clone()[0], + name: Messages.fm_sharedFolder, + }); + } + options.push({ separator: true }); + options.push({ + class: 'cp-app-drive-new-fileupload', + icon: getIcon('fileupload')[0], + name: Messages.uploadButton, + }); + if (APP.allowFolderUpload) { + options.push({ + class: 'cp-app-drive-new-folderupload', + icon: getIcon('folderupload')[0], + name: Messages.uploadFolderButton, + }); + } + options.push({ separator: true }); + options.push({ + class: 'cp-app-drive-new-link', + icon: getIcon('link')[0], + name: Messages.fm_link_new, + }); + options.push({ separator: true }); + } + getNewPadTypes().forEach(function (type) { + var typeClass = 'cp-app-drive-new-doc'; + + var premium = common.checkRestrictedApp(type); + if (premium < 0) { + typeClass += ' cp-app-hidden cp-app-disabled'; + } else if (premium === 0) { + typeClass += ' cp-app-disabled'; + } + + options.push({ + class: typeClass, + type: type, + icon: getIcon(type)[0], + name: Messages.type[type], + }); + }); + + if (APP.store[FILTER_BY]) { + var typeFilter = APP.store[FILTER_BY]; + options = options.filter((obj) => { + if (obj.separator) { return false; } + + if (typeFilter === 'link') { + return obj.class.includes('cp-app-drive-new-link'); + } + if (typeFilter === 'file') { + return obj.class.includes('cp-app-drive-new-fileupload'); + } + if (getNewPadTypes().indexOf(typeFilter) !== -1) { + return typeFilter === obj.type; + } + }); + } + + return options; + }; var createNewButton = function (isInRoot, $container) { if (!APP.editable) { return; } if (!APP.loggedIn) { return; } // Anonymous users can use the + menu in the toolbar if (!manager.isPathIn(currentPath, [ROOT, 'hrefArray'])) { return; } + // Create dropdown + var options = getNewPadOptions(isInRoot).map(function (obj) { + if (obj.separator) { return { tag: 'hr' }; } + var newObj = { + tag: 'a', + attributes: { 'class': obj.class }, + content: [ obj.icon, obj.name ] + }; + if (obj.type) { + newObj.attributes['data-type'] = obj.type; + newObj.attributes['href'] = '#'; + } + return newObj; + }); + var dropdownConfig = { + buttonContent: [ + h('i.fa.fa-plus'), + h('span.cp-button-name', Messages.fm_newButton), + ], + buttonCls: 'cp-toolbar-dropdown-nowrap', + options: options, + feedback: 'DRIVE_NEWPAD_LOCALFOLDER', + common: common + }; + var $block = UIElements.createDropdown(dropdownConfig); + + // Custom style: + $block.find('button').addClass('cp-app-drive-toolbar-new'); + + addNewPadHandlers($block, isInRoot); + + $container.append($block); + }; + + var createFilterButton = function (isTemplate, $container) { + if (!APP.loggedIn) { return; } + // Create dropdown var options = []; - if (isInRoot) { + if (APP.store[FILTER_BY]) { options.push({ tag: 'a', - attributes: {'class': 'cp-app-drive-new-folder pewpew'}, + attributes: { + 'class': 'cp-app-drive-rm-filter', + }, content: [ - $folderIcon.clone()[0], - Messages.fm_folder, - ], - }); - if (!APP.disableSF && !manager.isInSharedFolder(currentPath)) { - options.push({ - tag: 'a', - attributes: {'class': 'cp-app-drive-new-shared-folder'}, - content: [ - $sharedFolderIcon.clone()[0], - Messages.fm_sharedFolder, - ], - }); - } - options.push({tag: 'hr'}); - options.push({ - tag: 'a', - attributes: {'class': 'cp-app-drive-new-fileupload'}, - content: [ - getIcon('fileupload')[0], - Messages.uploadButton, - ], - }); - if (APP.allowFolderUpload) { - options.push({ - tag: 'a', - attributes: {'class': 'cp-app-drive-new-folderupload'}, - content: [ - getIcon('folderupload')[0], - Messages.uploadFolderButton, - ], - }); - } - options.push({tag: 'hr'}); - options.push({ - tag: 'a', - attributes: {'class': 'cp-app-drive-new-link'}, - content: [ - getIcon('link')[0], - Messages.fm_link_new, + h('i.fa.fa-times'), + Messages.fm_rmFilter, ], }); options.push({tag: 'hr'}); } getNewPadTypes().forEach(function (type) { var attributes = { - 'class': 'cp-app-drive-new-doc', + 'class': 'cp-app-drive-filter-doc', 'data-type': type, 'href': '#' }; @@ -3026,21 +3105,72 @@ define([ ], }); }); + if (!isTemplate) { + options.push({tag: 'hr'}); + options.push({ + tag: 'a', + attributes: { + 'class': 'cp-app-drive-filter-doc', + 'data-type': 'link' + }, + content: [ + getIcon('link')[0], + Messages.fm_link_type, + ], + }); + options.push({ + tag: 'a', + attributes: { + 'class': 'cp-app-drive-filter-doc', + 'data-type': 'file', + 'href': '#' + }, + content: [ + getIcon('file')[0], + Messages.type['file'], + ], + }); + } var dropdownConfig = { buttonContent: [ - h('span.fa.fa-plus'), - h('span', Messages.fm_newButton), + h('i.fa.fa-filter'), + h('span.cp-button-name', Messages.fm_filterBy), ], + buttonCls: 'cp-toolbar-dropdown-nowrap', options: options, - feedback: 'DRIVE_NEWPAD_LOCALFOLDER', + feedback: 'DRIVE_FILTERBY', common: common }; + if (APP.store[FILTER_BY]) { + var type = APP.store[FILTER_BY]; + var message = type === 'link' ? Messages.fm_link_type : Messages.type[type]; + dropdownConfig.buttonContent.push( + h('span.cp-button-name', ':'), + getIcon(type)[0], + h('span.cp-button-name', message) + ); + } var $block = UIElements.createDropdown(dropdownConfig); - // Custom style: - $block.find('button').addClass('cp-app-drive-toolbar-new'); + // Add style + if (APP.store[FILTER_BY]) { + $block.find('button').addClass('cp-toolbar-button-active'); + } - addNewPadHandlers($block, isInRoot); + // Add handlers + if (APP.store[FILTER_BY]) { + $block.find('a.cp-app-drive-rm-filter') + .click(function () { + APP.store[FILTER_BY] = undefined; + APP.displayDirectory(currentPath); + }); + } + $block.find('a.cp-app-drive-filter-doc') + .click(function () { + var type = $(this).attr('data-type') || 'invalid-filter'; + APP.store[FILTER_BY] = type; + APP.displayDirectory([FILTER, type, currentPath]); + }); $container.append($block); }; @@ -3302,65 +3432,38 @@ define([ return keys; }; + var filterPads = function (files, type, path, useId) { + var root = path && manager.find(path); + + return files + .filter(function (e) { + return useId ? manager.isFile(e) : (path && manager.isFile(root[e])); + }) + .filter(function (e) { + var id = useId ? e : root[e]; + var data = manager.getFileData(id); + if (type === 'link') { return data.static; } + var href = data.href || data.roHref; + return href ? (href.split('/')[1] === type) : true; + // if types are unreachable, display files to avoid misleading the user + }); + }; + // Create the ghost icon to add pads/folders var createNewPadIcons = function ($block, isInRoot) { var $container = $('
'); - if (isInRoot) { - // Folder - var $element1 = $('
  • ', { - 'class': 'cp-app-drive-new-folder cp-app-drive-element-row ' + - 'cp-app-drive-element-grid' - }).prepend($folderIcon.clone()).appendTo($container); - $element1.append($('', { 'class': 'cp-app-drive-new-name' }) - .text(Messages.fm_folder)); - // Shared Folder - if (!APP.disableSF && !manager.isInSharedFolder(currentPath)) { - var $element3 = $('
  • ', { - 'class': 'cp-app-drive-new-shared-folder cp-app-drive-element-row ' + - 'cp-app-drive-element-grid' - }).prepend($sharedFolderIcon.clone()).appendTo($container); - $element3.append($('', { 'class': 'cp-app-drive-new-name' }) - .text(Messages.fm_sharedFolder)); - } - // Upload file - var $elementFileUpload = $('
  • ', { - 'class': 'cp-app-drive-new-fileupload cp-app-drive-element-row ' + - 'cp-app-drive-element-grid' - }).prepend(getIcon('fileupload')).appendTo($container); - $elementFileUpload.append($('', {'class': 'cp-app-drive-new-name'}) - .text(Messages.uploadButton)); - // Upload folder - if (APP.allowFolderUpload) { - var $elementFolderUpload = $('
  • ', { - 'class': 'cp-app-drive-new-folderupload cp-app-drive-element-row ' + - 'cp-app-drive-element-grid' - }).prepend(getIcon('folderupload')).appendTo($container); - $elementFolderUpload.append($('', {'class': 'cp-app-drive-new-name'}) - .text(Messages.uploadFolderButton)); - } - // Link - var $elementLink = $('
  • ', { - 'class': 'cp-app-drive-new-link cp-app-drive-element-row ' + - 'cp-app-drive-element-grid' - }).prepend(getIcon('link')).appendTo($container); - $elementLink.append($('', {'class': 'cp-app-drive-new-name'}) - .text(Messages.fm_link_type)); - } - // Pads - getNewPadTypes().forEach(function (type) { - var $element = $('
  • ', { - 'class': 'cp-app-drive-new-doc cp-app-drive-element-row ' + - 'cp-app-drive-element-grid' - }).prepend(getIcon(type)).appendTo($container); - $element.append($('', {'class': 'cp-app-drive-new-name'}) - .text(Messages.type[type])); - $element.attr('data-type', type); + getNewPadOptions(isInRoot).forEach(function (obj) { + if (obj.separator) { return; } - var premium = common.checkRestrictedApp(type); - if (premium < 0) { - $element.addClass('cp-app-hidden cp-app-disabled'); - } else if (premium === 0) { - $element.addClass('cp-app-disabled'); + var $element = $('
  • ', { + 'class': obj.class + ' cp-app-drive-element-row ' + + 'cp-app-drive-element-grid' + }).prepend(obj.icon).appendTo($container); + $element.append($('', { 'class': 'cp-app-drive-new-name' }) + .text(obj.name)); + + if (obj.type) { + $element.attr('data-type', obj.type); } }); @@ -3435,7 +3538,7 @@ define([ // Unsorted element are represented by "href" in an array: they don't have a filename // and they don't hav a hierarchical structure (folder/subfolders) - var displayHrefArray = function ($container, rootName, draggable) { + var displayHrefArray = function ($container, rootName, draggable, typeFilter) { var unsorted = files[rootName]; if (unsorted.length) { var $fileHeader = getFileListHeader(true); @@ -3445,6 +3548,7 @@ define([ var sortBy = APP.store[SORT_FILE_BY]; sortBy = sortBy === "" ? sortBy = 'name' : sortBy; var sortedFiles = sortElements(false, [rootName], keys, sortBy, !getSortFileDesc(), true); + sortedFiles = typeFilter ? filterPads(sortedFiles, typeFilter, false, true) : sortedFiles; sortedFiles.forEach(function (id) { var file = manager.getFileData(id); if (!file) { @@ -3526,7 +3630,7 @@ define([ createGhostIcon($container); }; - var displayTrashRoot = function ($list, $folderHeader, $fileHeader) { + var displayTrashRoot = function ($list, $folderHeader, $fileHeader, typeFilter) { var filesList = []; var root = files[TRASH]; var isEmpty = true; @@ -3549,14 +3653,25 @@ define([ isEmpty = false; }); + var sortedFolders = typeFilter ? [] : sortTrashElements(true, filesList, null, !getSortFolderDesc()); + var sortedFiles = sortTrashElements(false, filesList, APP.store[SORT_FILE_BY], !getSortFileDesc); + + if (typeFilter) { + var ids = sortedFiles.map(function (obj) { return obj.element; }); + var idsFilter = filterPads(ids, typeFilter, false, true); + sortedFiles = sortedFiles.filter(function (obj) { + return (idsFilter.indexOf(obj.element) !== -1); + }); + // prevent trash emptying while filter is active + isEmpty = true; + } + if (!isEmpty) { var $empty = createEmptyTrashButton(); $content.append($empty); } - var sortedFolders = sortTrashElements(true, filesList, null, !getSortFolderDesc()); - var sortedFiles = sortTrashElements(false, filesList, APP.store[SORT_FILE_BY], !getSortFileDesc()); - if (manager.hasSubfolder(root, true)) { $list.append($folderHeader); } + if (!typeFilter && manager.hasSubfolder(root, true)) { $list.append($folderHeader); } sortedFolders.forEach(function (f) { var $element = createElement([TRASH], f.spath, root, true); $list.append($element); @@ -3728,7 +3843,7 @@ define([ }); }; - var displayRecent = function ($list) { + var displayRecent = function ($list, typeFilter) { var filesList = manager.getRecentPads(); var limit = 20; @@ -3744,6 +3859,14 @@ define([ var i = 0; var channels = []; + if (typeFilter) { + var ids = filesList.map(function (arr) { return arr[0]; }); + var idsFilter = filterPads(ids, typeFilter, false, true); + filesList = filesList.filter(function (arr) { + return (idsFilter.indexOf(arr[0]) !== -1); + }); + } + $list.append(h('li.cp-app-drive-element-separator', h('span', Messages.drive_active1Day))); filesList.some(function (arr) { var id = arr[0]; @@ -3932,6 +4055,17 @@ define([ $content.html(""); sel.$selectBox = $('
    ', {'class': 'cp-app-drive-content-select-box'}) .appendTo($content); + + var typeFilter; + var isFilter = path[0] === FILTER; + if (isFilter) { + if (path.length < 3) { return; } + typeFilter = path[1]; + path = path[2]; + currentPath = path; + } else { + APP.store[FILTER_BY] = undefined; + } var isInRoot = manager.isPathIn(path, [ROOT]); var inTrash = manager.isPathIn(path, [TRASH]); var isTrashRoot = manager.comparePath(path, [TRASH]); @@ -3939,6 +4073,8 @@ define([ var isAllFiles = manager.comparePath(path, [FILES_DATA]); var isVirtual = virtualCategories.indexOf(path[0]) !== -1; var isSearch = path[0] === SEARCH; + var isRecent = path[0] === RECENT; + var isOwned = path[0] === OWNED; var isTags = path[0] === TAGS; // ANON_SHARED_FOLDER var isSharedFolder = path[0] === SHARED_FOLDER && APP.newSharedFolder; @@ -4040,6 +4176,9 @@ define([ if (!readOnlyFolder) { createNewButton(isInRoot, APP.toolbar.$bottomL); } + if (!isTags && !isSearch) { + createFilterButton(isTemplate, APP.toolbar.$bottomL); + } if (APP.mobile()) { var $context = $('