diff --git a/customize.dist/pages.js b/customize.dist/pages.js
index 43254ad27..57b2a501b 100644
--- a/customize.dist/pages.js
+++ b/customize.dist/pages.js
@@ -102,8 +102,7 @@ define([
Pages.privacyLink = footLink(AppConfig.privacy, 'privacy');
Pages.githubLink = footLink('https://github.com/xwiki-labs/cryptpad', null, 'GitHub');
Pages.docsLink = footLink('https://docs.cryptpad.fr', 'docs_link');
- Msg.home_roadmap = "Roadmap"; // XXX
- Pages.roadmapLink = footLink(AppConfig.roadmap, 'home_roadmap');
+ Pages.roadmapLink = footLink(AppConfig.roadmap, 'footer_roadmap');
Pages.infopageFooter = function () {
var terms = footLink('/terms.html', 'footer_tos'); // FIXME this should be configurable like the other legal pages
diff --git a/www/calendar/app-calendar.less b/www/calendar/app-calendar.less
index 88869667f..1ec6c295b 100644
--- a/www/calendar/app-calendar.less
+++ b/www/calendar/app-calendar.less
@@ -11,6 +11,10 @@
display: flex;
flex-flow: column;
+ .cp-toolbar-bottom-mid > div {
+ .cp-small { display: none; }
+ }
+
#cp-sidebarlayout-container #cp-sidebarlayout-rightside {
padding: 0;
& > div {
@@ -385,5 +389,43 @@
cursor: pointer;
border: 1px solid @cp_forms-border;
}
+
+ @media (max-width: @browser_media-medium-screen) {
+ .cp-calendar-newevent {
+ i {
+ margin: 0 !important;
+ }
+ span {
+ display: none;
+ }
+ }
+ .tui-full-calendar-dayname-leftmargin, .tui-full-calendar-timegrid-right {
+ margin-left: 40px !important;
+ }
+ .tui-full-calendar-allday-left, .tui-full-calendar-timegrid-left {
+ width: 40px !important;
+ }
+ .tui-full-calendar-dayname > span {
+ display: flex;
+ flex-flow: column;
+ line-height: 0;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ }
+ .tui-full-calendar-dayname * {
+ font-size: 11px;
+ line-height: initial;
+ height: auto;
+ }
+ .cp-toolbar-bottom-mid > div {
+ :not(:first-child) {
+ display: none;
+ }
+ :first-child {
+ display: inline-block;
+ }
+ }
+ }
}
diff --git a/www/calendar/inner.js b/www/calendar/inner.js
index 152acde99..3cde25436 100644
--- a/www/calendar/inner.js
+++ b/www/calendar/inner.js
@@ -70,7 +70,6 @@ Messages.calendar_deleteConfirm = "Are you sure you want to delete this calendar
Messages.calendar_deleteTeamConfirm = "Are you sure you want to delete this calendar from this team?";
Messages.calendar_deleteOwned = " It will still be visible for the users it has been shared with.";
Messages.calendar_errorNoCalendar = "No editable calendar selected!";
-Messages.calendar_myCalendars = "My calendars";
Messages.calendar_tempCalendar = "Viewing";
Messages.calendar_import = "Import to my calendars";
Messages.calendar_import_temp = "Import this calendar";
@@ -78,6 +77,7 @@ Messages.calendar_newEvent = "New event";
Messages.calendar_new = "New calendar";
Messages.calendar_dateRange = "{0} - {1}";
Messages.calendar_dateTimeRange = "{0} {1} - {2}";
+Messages.calendar_weekNumber = "Week {0}";
Messages.calendar_update = "Update";
Messages.calendar_title = "Title";
Messages.calendar_loc = "Location";
@@ -153,13 +153,16 @@ Messages.calendar_noNotification = "None";
return (brightness > 125) ? '#424242' : '#EEEEEE';
};
- var getWeekDays = function () {
+ var getWeekDays = function (large) {
var baseDate = new Date(Date.UTC(2017, 0, 1)); // just a Sunday
var weekDays = [];
for(var i = 0; i < 7; i++) {
weekDays.push(baseDate.toLocaleDateString(undefined, { weekday: 'long' }));
baseDate.setDate(baseDate.getDate() + 1);
}
+ if (!large) {
+ weekDays = weekDays.map(function (day) { return day.slice(0,3); });
+ }
return weekDays.map(function (day) { return day.replace(/^./, function (str) { return str.toUpperCase(); }); });
};
@@ -314,7 +317,6 @@ Messages.calendar_noNotification = "None";
}
};
- // XXX Note: always create calendars in your own proxy. If you want a team calendar, you can share it with the team later.
var editCalendar = function (id) {
var isNew = !id;
var data = APP.calendars[id];
@@ -449,7 +451,7 @@ Messages.calendar_noNotification = "None";
pathname: "/calendar/",
friends: friends,
title: title,
- password: cal.password, // XXX support passwords
+ password: cal.password,
calendar: {
title: title,
color: color,
@@ -476,7 +478,7 @@ Messages.calendar_noNotification = "None";
var href = Hash.hashToHref(h.editHash || h.viewHash, 'calendar');
Access.getAccessModal(common, {
title: title,
- password: cal.password, // XXX support passwords
+ password: cal.password,
calendar: {
title: title,
color: color,
@@ -633,7 +635,6 @@ Messages.calendar_noNotification = "None";
return UIElements.createDropdown(dropdownConfig)[0];
};
var makeCalendarEntry = function (id, teamId) {
- // XXX handle RESTRICTED calendars (data.restricted)
var data = APP.calendars[id];
var edit;
if (data.loading) {
@@ -686,8 +687,8 @@ Messages.calendar_noNotification = "None";
var filter = function (teamId) {
return Object.keys(APP.calendars || {}).filter(function (id) {
var cal = APP.calendars[id] || {};
- var teams = cal.teams || [];
- return teams.indexOf(typeof(teamId) !== "undefined" ? teamId : 1) !== -1;
+ var teams = (cal.teams || []).map(function (tId) { return Number(tId); });
+ return teams.indexOf(typeof(teamId) !== "undefined" ? Number(teamId) : 1) !== -1;
});
};
var tempCalendars = filter(0);
@@ -717,8 +718,13 @@ Messages.calendar_noNotification = "None";
}
var myCalendars = filter(1);
if (myCalendars.length) {
+ var user = metadataMgr.getUserData();
+ var avatar = h('span.cp-avatar');
+ var name = user.name || Messages.anonymous;
+ common.displayAvatar($(avatar), user.avatar, name);
APP.$calendars.append(h('div.cp-calendar-team', [
- h('span', Messages.calendar_myCalendars)
+ avatar,
+ h('span.cp-name', {title: name}, name)
]));
}
myCalendars.forEach(function (id) {
@@ -754,11 +760,26 @@ Messages.calendar_noNotification = "None";
onCalendarsUpdate.fire();
};
+
+ 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),
@@ -791,6 +812,7 @@ Messages.calendar_noNotification = "None";
h('div#cp-sidebarlayout-rightside')
]);
+ var large = $(window).width() >= 800;
var cal = APP.calendar = new Calendar('#cp-sidebarlayout-rightside', {
defaultView: view || 'week', // weekly view option
taskView: false,
@@ -800,19 +822,36 @@ Messages.calendar_noNotification = "None";
calendars: getCalendars(),
template: templates,
month: {
- daynames: getWeekDays(),
+ daynames: getWeekDays(large),
startDayOfWeek: 1,
},
week: {
- daynames: getWeekDays(),
+ daynames: getWeekDays(large),
startDayOfWeek: 1,
}
});
+ $(window).on('resize', function () {
+ var _large = $(window).width() >= 800;
+ if (large !== _large) {
+ large = _large;
+ cal.setOptions({
+ month: {
+ daynames: getWeekDays(_large),
+ startDayOfWeek: 1,
+ },
+ week: {
+ daynames: getWeekDays(_large),
+ startDayOfWeek: 1,
+ }
+ });
+ }
+ });
+
makeLeftside(cal, $(leftside));
cal.on('beforeCreateSchedule', function(event) {
- // XXX Recurrence (later)
+ // 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}
@@ -925,7 +964,7 @@ Messages.calendar_noNotification = "None";
APP.toolbar.$bottomR.append($block);
// New event button
- var newEventBtn = h('button', [
+ var newEventBtn = h('button.cp-calendar-newevent', [
h('i.fa.fa-plus'),
h('span', Messages.calendar_newEvent)
]);
@@ -974,7 +1013,7 @@ Messages.calendar_noNotification = "None";
var getNotificationDropdown = function () {
var ev = APP.editModalData;
var calId = ev.selectedCal.id;
- // XXX DEFAULT HERE [10] ==> 10 minutes before the event
+ // DEFAULT HERE [10] ==> 10 minutes before the event
var oldReminders = Util.find(APP.calendars, [calId, 'content', 'content', ev.id, 'reminders']) || [10];
APP.notificationsEntries = [];
var number = h('input.tui-full-calendar-content', {
diff --git a/www/common/application_config_internal.js b/www/common/application_config_internal.js
index ee4d82288..f1b2f94bf 100644
--- a/www/common/application_config_internal.js
+++ b/www/common/application_config_internal.js
@@ -45,10 +45,12 @@ define(function() {
*/
// config.privacy = 'https://xwiki.com/en/company/PrivacyPolicy';
- /* XXX
- *
+ /* We (the project's developers) include the ability to display a 'Roadmap' in static pages footer.
+ * This is disabled by default.
+ * We use this to publish the project's development roadmap, but you can use it however you like.
+ * To do so, set the following value to an absolute URL.
*/
- //config.roadmap = 'https://cryptpad.fr/kanban/#/2/kanban/view/PLM0C3tFWvYhd+EPzXrbT+NxB76Z5DtZhAA5W5hG9wo/'; // XXX
+ //config.roadmap = 'https://cryptpad.fr/kanban/#/2/kanban/view/PLM0C3tFWvYhd+EPzXrbT+NxB76Z5DtZhAA5W5hG9wo/';
/* Cryptpad apps use a common API to display notifications to users
* by default, notifications are hidden after 5 seconds
diff --git a/www/common/diffMarked.js b/www/common/diffMarked.js
index 71b2185ba..f2fb97f24 100644
--- a/www/common/diffMarked.js
+++ b/www/common/diffMarked.js
@@ -9,7 +9,7 @@ define([
'/common/media-tag.js',
'/customize/messages.js',
'/common/highlight/highlight.pack.js',
- '/bower_components/diff-dom/diffDOM.js',
+ '/lib/diff-dom/diffDOM.js',
'/bower_components/tweetnacl/nacl-fast.min.js',
'css!/common/highlight/styles/'+ (window.CryptPad_theme === 'dark' ? 'dark.css' : 'github.css')
],function ($, ApiConfig, Marked, Hash, Util, h, MT, MediaTag, Messages) {
diff --git a/www/common/onlyoffice/inner.js b/www/common/onlyoffice/inner.js
index 9c42951b6..fcf010d96 100644
--- a/www/common/onlyoffice/inner.js
+++ b/www/common/onlyoffice/inner.js
@@ -356,10 +356,39 @@ define([
}
};
+ // Add a lock
+ var isLockedModal = {
+ content: UI.dialog.customModal(h('div.cp-oo-x2tXls', [
+ h('span.fa.fa-spin.fa-spinner'),
+ h('span', Messages.oo_isLocked)
+ ]))
+ };
+
var onUploaded = function (ev, data, err) {
content.saveLock = undefined;
if (err) {
console.error(err);
+ if (content.saveLock === myOOId) { delete content.saveLock; } // Unlock checkpoints
+ if (APP.migrateModal) {
+ try { getEditor().asc_setRestriction(true); } catch (e) {}
+ setEditable(true);
+ delete content.migration;
+ APP.migrateModal.closeModal();
+ APP.onLocal();
+ }
+ if (isLockedModal.modal && err === "TOO_LARGE") {
+ if (APP.migrate) {
+ UI.warn(Messages.oo_cantMigrate);
+ }
+ APP.cantCheckpoint = true;
+ isLockedModal.modal.closeModal();
+ delete isLockedModal.modal;
+ if (content.saveLock === myOOId) {
+ delete content.saveLock;
+ }
+ APP.onLocal();
+ return;
+ }
return void UI.alert(Messages.oo_saveError);
}
// Get the last cp idx
@@ -422,14 +451,6 @@ define([
};
APP.FM = common.createFileManager(fmConfig);
- // Add a lock
- var isLockedModal = {
- content: UI.dialog.customModal(h('div.cp-oo-x2tXls', [
- h('span.fa.fa-spin.fa-spinner'),
- h('span', Messages.oo_isLocked)
- ]))
- };
-
var resetData = function (blob, type) {
// If a read-only refresh popup was planned, abort it
delete APP.refreshPopup;
@@ -453,7 +474,19 @@ define([
};
var saveToServer = function () {
+ if (APP.cantCheckpoint) { return; } // TOO_LARGE
var text = getContent();
+ if (!text) {
+ setEditable(false, true);
+ sframeChan.query('Q_CLEAR_CACHE_CHANNELS', [
+ 'chainpad',
+ content.channel,
+ ], function () {});
+ UI.alert(Messages.realtime_unrecoverableError, function () {
+ common.gotoURL();
+ });
+ return;
+ }
var blob = new Blob([text], {type: 'plain/text'});
var file = getFileType();
blob.name = (metadataMgr.getMetadataLazy().title || file.doc) + '.' + file.type;
@@ -479,6 +512,8 @@ define([
var noLogin = false;
var makeCheckpoint = function (force) {
+ if (APP.cantCheckpoint) { return; } // TOO_LARGE
+
var locked = content.saveLock;
var lastCp = getLastCp();
@@ -1374,7 +1409,7 @@ define([
h('span.fa.fa-spin.fa-spinner'),
h('span', Messages.oo_sheetMigration_loading)
]);
- UI.openCustomModal(UI.dialog.customModal(div, {buttons: []}));
+ APP.migrateModal = UI.openCustomModal(UI.dialog.customModal(div, {buttons: []}));
makeCheckpoint(true);
});
// DEPRECATED: from version 3, the queue is sent again during init
diff --git a/www/common/outer/calendar.js b/www/common/outer/calendar.js
index 1bb5fd570..33453b6ee 100644
--- a/www/common/outer/calendar.js
+++ b/www/common/outer/calendar.js
@@ -114,7 +114,7 @@ define([
}
}
- // XXX add a limit to make sure we don't go too far in the past?
+ // XXX add a limit to make sure we don't go too far in the past? ==> 1 week
var missed = useLastVisit && ev.start > last && ev.end <= now;
if (ev.end <= now && !missed) {
// No reminder for past events
@@ -283,7 +283,6 @@ define([
var onDeleted = function () {
// Remove this calendar from all our teams
- // XXX Maybe not? don't remove automatically so that we can tell the user to do so.
c.stores.forEach(function (storeId) {
var store = getStore(ctx, storeId);
if (!store || !store.rpc || !store.proxy.calendars) { return; }
@@ -412,6 +411,7 @@ define([
}
if (info.error === "ERESTRICTED" ) {
c.restricted = true;
+ setTimeout(update);
}
cb(info);
});
@@ -846,7 +846,7 @@ define([
Calendar.init = function (cfg, waitFor, emit) {
var calendar = {};
var store = cfg.store;
- if (!store.loggedIn || !store.proxy.edPublic) { return; } // XXX logged in only?
+ if (!store.loggedIn || !store.proxy.edPublic) { return; } // XXX logged in only? we should al least allow read-only for URL calendars
var ctx = {
store: store,
Store: cfg.Store,
diff --git a/www/common/sframe-common-file.js b/www/common/sframe-common-file.js
index c7a66adb9..2a6f4fea3 100644
--- a/www/common/sframe-common-file.js
+++ b/www/common/sframe-common-file.js
@@ -133,6 +133,7 @@ define([
$pv.text(Messages.error);
queue.inProgress = false;
queue.next();
+ if (config.onError) { config.onError("TOO_LARGE"); }
return void UI.alert(Messages._getKey('upload_tooLargeBrief', [maxSizeStr]));
}
diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js
index dde533e9f..d24f62f9d 100644
--- a/www/common/sframe-common-outer.js
+++ b/www/common/sframe-common-outer.js
@@ -44,8 +44,8 @@ define([
// loading screen setup.
var done = waitFor();
var onMsg = function (msg) {
- var data = JSON.parse(msg.data);
- if (data.q !== 'READY') { return; }
+ var data = typeof(msg.data) === "string" ? JSON.parse(msg.data) : msg.data;
+ if (!data || data.q !== 'READY') { return; }
window.removeEventListener('message', onMsg);
var _done = done;
done = function () { };
@@ -182,8 +182,8 @@ define([
};
var whenReady = waitFor(function (msg) {
if (msg.source !== iframe) { return; }
- var data = JSON.parse(msg.data);
- if (!data.txid) { return; }
+ var data = typeof(msg.data) === "string" ? JSON.parse(msg.data) : msg.data;
+ if (!data || !data.txid) { return; }
// Remove the listener once we've received the READY message
window.removeEventListener('message', whenReady);
// Answer with the requested data
@@ -1533,6 +1533,16 @@ define([
sframeChan.on('Q_CLEAR_CACHE', function (data, cb) {
Utils.Cache.clear(cb);
});
+ sframeChan.on('Q_CLEAR_CACHE_CHANNELS', function (channels, cb) {
+ if (!Array.isArray(channels)) { return void cb({error: "NOT_AN_ARRAY"}); }
+ nThen(function (waitFor) {
+ channels.forEach(function (chan) {
+ if (chan === "chainpad") { chan = secret.channel; }
+ console.error(chan);
+ Utils.Cache.clearChannel(chan, waitFor());
+ });
+ }).nThen(cb);
+ });
sframeChan.on('Q_PIN_GET_USAGE', function (teamId, cb) {
Cryptpad.isOverPinLimit(teamId, function (err, overLimit, data) {
diff --git a/www/common/translations/messages.de.json b/www/common/translations/messages.de.json
index c7d28db09..f01a473e2 100644
--- a/www/common/translations/messages.de.json
+++ b/www/common/translations/messages.de.json
@@ -541,7 +541,7 @@
"four04_pageNotFound": "Wir konnten die Seite, die du angefordert hast, nicht finden.",
"header_logoTitle": "Zu deinem CryptDrive",
"header_homeTitle": "Zur CryptPad-Hauptseite",
- "help_genericMore": "Erfahre mehr über die Nutzung von CryptPad, indem du unsere Documentation liest.",
+ "help_genericMore": "Erfahre mehr über die Nutzung von CryptPad, indem du unsere Dokumentation liest.",
"driveReadmeTitle": "Was ist CryptPad?",
"edit": "Bearbeiten",
"view": "Ansehen",
diff --git a/www/common/translations/messages.fr.json b/www/common/translations/messages.fr.json
index cd3c86130..dab1481dd 100644
--- a/www/common/translations/messages.fr.json
+++ b/www/common/translations/messages.fr.json
@@ -1181,5 +1181,7 @@
"admin_maintenanceButton": "Planifier maintenance",
"admin_maintenanceHint": "Planifier une maintenance sur cette instance et avertir tous les utilisateurs. Limité à une maintenance active à la fois.",
"admin_maintenanceTitle": "Maintenance",
- "admin_cat_broadcast": "Annonces"
+ "admin_cat_broadcast": "Annonces",
+ "oo_cantMigrate": "Ce tableur dépasse la taille maximale de téléchargement et est trop grand pour être mis à jour.",
+ "footer_roadmap": "Feuille de route"
}
diff --git a/www/common/translations/messages.json b/www/common/translations/messages.json
index c0ecbb163..a725cb012 100644
--- a/www/common/translations/messages.json
+++ b/www/common/translations/messages.json
@@ -1184,5 +1184,7 @@
"broadcast_newCustom": "Message from the administrators",
"settings_deleteWarning": "Warning: you are currently subscribed to a premium plan (paid or given by another user). Please cancel your plan before deleting your account as will not be possible without contacting support once your account is deleted.",
"settings_deleteContinue": "Delete my account",
- "settings_deleteSubscription": "Manage my subscription"
+ "settings_deleteSubscription": "Manage my subscription",
+ "footer_roadmap": "Roadmap",
+ "oo_cantMigrate": "This sheet exceeds the maximum upload size and is too large to be migrated."
}
diff --git a/www/kanban/inner.js b/www/kanban/inner.js
index 3428bd12b..126ffd000 100644
--- a/www/kanban/inner.js
+++ b/www/kanban/inner.js
@@ -30,10 +30,6 @@ define([
'css!/bower_components/codemirror/lib/codemirror.css',
'css!/bower_components/codemirror/addon/dialog/dialog.css',
'css!/bower_components/codemirror/addon/fold/foldgutter.css',
-
-
-
- 'css!/kanban/jkanban.css',
'less!/kanban/app-kanban.less'
], function (
$,
diff --git a/www/lib/diff-dom/diffDOM.js b/www/lib/diff-dom/diffDOM.js
new file mode 100755
index 000000000..deca9be40
--- /dev/null
+++ b/www/lib/diff-dom/diffDOM.js
@@ -0,0 +1,1358 @@
+(function() {
+ "use strict";
+
+ var diffcount;
+
+ var Diff = function (options) {
+ var diff = this;
+ Object.keys(options).forEach(function(option) {
+ diff[option] = options[option];
+ });
+ };
+
+ Diff.prototype = {
+ toString: function() {
+ return JSON.stringify(this);
+ }
+
+ // TODO: compress diff output by replacing these keys with numbers or alike:
+ /* 'addAttribute' = 0,
+ 'modifyAttribute' = 1,
+ 'removeAttribute' = 2,
+ 'modifyTextElement' = 3,
+ 'relocateGroup' = 4,
+ 'removeElement' = 5,
+ 'addElement' = 6,
+ 'removeTextElement' = 7,
+ 'addTextElement' = 8,
+ 'replaceElement' = 9,
+ 'modifyValue' = 10,
+ 'modifyChecked' = 11,
+ 'modifySelected' = 12,
+ 'modifyComment' = 13,
+ 'action' = 14,
+ 'route' = 15,
+ 'oldValue' = 16,
+ 'newValue' = 17,
+ 'element' = 18,
+ 'group' = 19,
+ 'from' = 20,
+ 'to' = 21,
+ 'name' = 22,
+ 'value' = 23,
+ 'data' = 24,
+ 'attributes' = 25,
+ 'nodeName' = 26,
+ 'childNodes' = 27,
+ 'checked' = 28,
+ 'selected' = 29;*/
+ };
+
+ var SubsetMapping = function SubsetMapping(a, b) {
+ this.oldValue = a;
+ this.newValue = b;
+ };
+
+ SubsetMapping.prototype = {
+ contains: function contains(subset) {
+ if (subset.length < this.length) {
+ return subset.newValue >= this.newValue && subset.newValue < this.newValue + this.length;
+ }
+ return false;
+ },
+ toString: function toString() {
+ return this.length + " element subset, first mapping: old " + this.oldValue + " → new " + this.newValue;
+ }
+ };
+
+ var elementDescriptors = function(el) {
+ var output = [];
+ if (el.nodeName !== '#text' && el.nodeName !== '#comment') {
+ output.push(el.nodeName);
+ if (el.attributes) {
+ if (el.attributes['class']) {
+ output.push(el.nodeName + '.' + el.attributes['class'].replace(/ /g, '.'));
+ }
+ if (el.attributes.id) {
+ output.push(el.nodeName + '#' + el.attributes.id);
+ }
+ }
+
+ }
+ return output;
+ };
+
+ var findUniqueDescriptors = function(li) {
+ var uniqueDescriptors = {},
+ duplicateDescriptors = {};
+
+ li.forEach(function(node) {
+ elementDescriptors(node).forEach(function(descriptor) {
+ var inUnique = descriptor in uniqueDescriptors,
+ inDupes = descriptor in duplicateDescriptors;
+ if (!inUnique && !inDupes) {
+ uniqueDescriptors[descriptor] = true;
+ } else if (inUnique) {
+ delete uniqueDescriptors[descriptor];
+ duplicateDescriptors[descriptor] = true;
+ }
+ });
+
+ });
+
+ return uniqueDescriptors;
+ };
+
+ var uniqueInBoth = function(l1, l2) {
+ var l1Unique = findUniqueDescriptors(l1),
+ l2Unique = findUniqueDescriptors(l2),
+ inBoth = {};
+
+ Object.keys(l1Unique).forEach(function(key) {
+ if (l2Unique[key]) {
+ inBoth[key] = true;
+ }
+ });
+
+ return inBoth;
+ };
+
+ var removeDone = function(tree) {
+ delete tree.outerDone;
+ delete tree.innerDone;
+ delete tree.valueDone;
+ if (tree.childNodes) {
+ return tree.childNodes.every(removeDone);
+ } else {
+ return true;
+ }
+ };
+
+ var isEqual = function(e1, e2) {
+
+ var e1Attributes, e2Attributes;
+
+ if (!['nodeName', 'value', 'checked', 'selected', 'data'].every(function(element) {
+ if (e1[element] !== e2[element]) {
+ return false;
+ }
+ return true;
+ })) {
+ return false;
+ }
+
+ if (Boolean(e1.attributes) !== Boolean(e2.attributes)) {
+ return false;
+ }
+
+ if (Boolean(e1.childNodes) !== Boolean(e2.childNodes)) {
+ return false;
+ }
+
+ if (e1.attributes) {
+ e1Attributes = Object.keys(e1.attributes);
+ e2Attributes = Object.keys(e2.attributes);
+
+ if (e1Attributes.length !== e2Attributes.length) {
+ return false;
+ }
+ if (!e1Attributes.every(function(attribute) {
+ if (e1.attributes[attribute] !== e2.attributes[attribute]) {
+ return false;
+ }
+ })) {
+ return false;
+ }
+ }
+
+ if (e1.childNodes) {
+ if (e1.childNodes.length !== e2.childNodes.length) {
+ return false;
+ }
+ if (!e1.childNodes.every(function(childNode, index) {
+ return isEqual(childNode, e2.childNodes[index]);
+ })) {
+
+ return false;
+ }
+
+ }
+
+ return true;
+
+ };
+
+
+ var roughlyEqual = function(e1, e2, uniqueDescriptors, sameSiblings, preventRecursion) {
+ var childUniqueDescriptors, nodeList1, nodeList2;
+
+ if (!e1 || !e2) {
+ return false;
+ }
+
+ if (e1.nodeName !== e2.nodeName) {
+ return false;
+ }
+
+ if (e1.nodeName === '#text') {
+ // Note that we initially don't care what the text content of a node is,
+ // the mere fact that it's the same tag and "has text" means it's roughly
+ // equal, and then we can find out the true text difference later.
+ return preventRecursion ? true : e1.data === e2.data;
+ }
+
+
+ if (e1.nodeName in uniqueDescriptors) {
+ return true;
+ }
+
+ if (e1.attributes && e2.attributes) {
+
+ if (e1.attributes.id && e1.attributes.id === e2.attributes.id) {
+ var idDescriptor = e1.nodeName + '#' + e1.attributes.id;
+ if (idDescriptor in uniqueDescriptors) {
+ return true;
+ }
+ }
+ if (e1.attributes['class'] && e1.attributes['class'] === e2.attributes['class']) {
+ var classDescriptor = e1.nodeName + '.' + e1.attributes['class'].replace(/ /g, '.');
+ if (classDescriptor in uniqueDescriptors) {
+ return true;
+ }
+ }
+ }
+
+ if (sameSiblings) {
+ return true;
+ }
+
+ nodeList1 = e1.childNodes ? e1.childNodes.slice().reverse() : [];
+ nodeList2 = e2.childNodes ? e2.childNodes.slice().reverse() : [];
+
+ if (nodeList1.length !== nodeList2.length) {
+ return false;
+ }
+
+ if (preventRecursion) {
+ return nodeList1.every(function(element, index) {
+ return element.nodeName === nodeList2[index].nodeName;
+ });
+ } else {
+ // note: we only allow one level of recursion at any depth. If 'preventRecursion'
+ // was not set, we must explicitly force it to true for child iterations.
+ childUniqueDescriptors = uniqueInBoth(nodeList1, nodeList2);
+ return nodeList1.every(function(element, index) {
+ return roughlyEqual(element, nodeList2[index], childUniqueDescriptors, true, true);
+ });
+ }
+ };
+
+
+ var cloneObj = function(obj) {
+ // TODO: Do we really need to clone here? Is it not enough to just return the original object?
+ return JSON.parse(JSON.stringify(obj));
+ //return obj;
+ };
+
+ /**
+ * based on https://en.wikibooks.org/wiki/Algorithm_implementation/Strings/Longest_common_substring#JavaScript
+ */
+ var findCommonSubsets = function(c1, c2, marked1, marked2) {
+ var lcsSize = 0,
+ index = [],
+ matches = Array.apply(null, new Array(c1.length + 1)).map(function() {
+ return [];
+ }), // set up the matching table
+ uniqueDescriptors = uniqueInBoth(c1, c2),
+ // If all of the elements are the same tag, id and class, then we can
+ // consider them roughly the same even if they have a different number of
+ // children. This will reduce removing and re-adding similar elements.
+ subsetsSame = c1.length === c2.length,
+ origin, ret;
+
+ if (subsetsSame) {
+
+ c1.some(function(element, i) {
+ var c1Desc = elementDescriptors(element),
+ c2Desc = elementDescriptors(c2[i]);
+ if (c1Desc.length !== c2Desc.length) {
+ subsetsSame = false;
+ return true;
+ }
+ c1Desc.some(function(description, i) {
+ if (description !== c2Desc[i]) {
+ subsetsSame = false;
+ return true;
+ }
+ });
+ if (!subsetsSame) {
+ return true;
+ }
+
+ });
+ }
+
+ // fill the matches with distance values
+ c1.forEach(function(c1Element, c1Index) {
+ c2.forEach(function(c2Element, c2Index) {
+ if (!marked1[c1Index] && !marked2[c2Index] && roughlyEqual(c1Element, c2Element, uniqueDescriptors, subsetsSame)) {
+ matches[c1Index + 1][c2Index + 1] = (matches[c1Index][c2Index] ? matches[c1Index][c2Index] + 1 : 1);
+ if (matches[c1Index + 1][c2Index + 1] >= lcsSize) {
+ lcsSize = matches[c1Index + 1][c2Index + 1];
+ index = [c1Index + 1, c2Index + 1];
+ }
+ } else {
+ matches[c1Index + 1][c2Index + 1] = 0;
+ }
+ });
+ });
+ if (lcsSize === 0) {
+ return false;
+ }
+ origin = [index[0] - lcsSize, index[1] - lcsSize];
+ ret = new SubsetMapping(origin[0], origin[1]);
+ ret.length = lcsSize;
+
+ return ret;
+ };
+
+ /**
+ * This should really be a predefined function in Array...
+ */
+ var makeArray = function(n, v) {
+ return Array.apply(null, new Array(n)).map(function() {
+ return v;
+ });
+ };
+
+ /**
+ * Generate arrays that indicate which node belongs to which subset,
+ * or whether it's actually an orphan node, existing in only one
+ * of the two trees, rather than somewhere in both.
+ *
+ * So if t1 = ![]()